From 81452482852151c7e39b2ce3c6a91c7b7ed6793d Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 21 Feb 2024 00:54:34 +0000 Subject: [PATCH 01/10] Expose APIs for working with transaction proposals Closes Electric-Coin-Company/zcash-swift-wallet-sdk#1204. --- CHANGELOG.md | 10 ++ .../ZcashLightClientKit/Model/Proposal.swift | 18 +++ .../ZcashLightClientKit/Synchronizer.swift | 61 +++++++++- .../Synchronizer/ClosureSDKSynchronizer.swift | 33 ++++++ .../Synchronizer/CombineSDKSynchronizer.swift | 30 +++++ .../Synchronizer/SDKSynchronizer.swift | 106 +++++++++++++++-- .../Transaction/TransactionEncoder.swift | 74 ++++++------ .../WalletTransactionEncoder.swift | 109 ++++++------------ .../AutoMockable.generated.swift | 72 ++++++++++++ 9 files changed, 393 insertions(+), 120 deletions(-) create mode 100644 Sources/ZcashLightClientKit/Model/Proposal.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 20484429..3e87280c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this library will be documented in this file. 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 + +### [#1204] Expose APIs for working with transaction proposals +New `Synchronizer` APIs that enable constructing a proposal for transferring or +shielding funds, and then creating transactions from a proposal. The intermediate +proposal can be used to determine the required fee, before committing to producing +transactions. + # 2.0.10 - 2024-02-12 ## Added diff --git a/Sources/ZcashLightClientKit/Model/Proposal.swift b/Sources/ZcashLightClientKit/Model/Proposal.swift new file mode 100644 index 00000000..a2645c8a --- /dev/null +++ b/Sources/ZcashLightClientKit/Model/Proposal.swift @@ -0,0 +1,18 @@ +// +// Proposal.swift +// +// +// Created by Jack Grigg on 20/02/2024. +// + +import Foundation + +/// A data structure that describes a series of transactions to be created. +public struct Proposal: Equatable { + let inner: FfiProposal + + /// Returns the total fee to be paid across all proposed transactions, in zatoshis. + public func totalFeeRequired() -> Zatoshi { + Zatoshi(Int64(inner.balance.feeRequired)) + } +} diff --git a/Sources/ZcashLightClientKit/Synchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer.swift index f675d0da..01158640 100644 --- a/Sources/ZcashLightClientKit/Synchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer.swift @@ -154,7 +154,53 @@ public protocol Synchronizer: AnyObject { /// - Parameter accountIndex: the optional accountId whose address is of interest. By default, the first account is used. /// - Returns the address or nil if account index is incorrect func getTransparentAddress(accountIndex: Int) async throws -> TransparentAddress - + + /// Creates a proposal for transferring funds to the given recipient. + /// + /// - Parameter accountIndex: the account from which to transfer funds. + /// - Parameter recipient: the recipient's address. + /// - Parameter amount: the amount to send in Zatoshi. + /// - Parameter memo: an optional memo to include as part of the proposal's transactions. Use `nil` when sending to transparent receivers otherwise the function will throw an error. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws + /// `SynchronizerErrors.notPrepared`. + func proposeTransfer( + accountIndex: Int, + recipient: Recipient, + amount: Zatoshi, + memo: Memo? + ) async throws -> Proposal + + /// Creates a proposal for shielding any transparent funds received by the given account. + /// + /// - Parameter accountIndex: the account for which to shield funds. + /// - Parameter shieldingThreshold: the minimum transparent balance required before a proposal will be created. + /// - Parameter memo: an optional memo to include as part of the proposal's transactions. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws + /// `SynchronizerErrors.notPrepared`. + func proposeShielding( + accountIndex: Int, + shieldingThreshold: Zatoshi, + memo: Memo + ) async throws -> Proposal + + /// Creates the transactions in the given proposal. + /// + /// - Parameter proposal: the proposal for which to create transactions. + /// - Parameter spendingKey: the `UnifiedSpendingKey` associated with the account for which the proposal was created. + /// + /// Returns a stream of objects for the transactions that were created as part of the + /// proposal, indicating whether they were submitted to the network or if an error + /// occurred. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance + /// or since the last wipe then this method throws `SynchronizerErrors.notPrepared`. + func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) async throws -> AsyncThrowingStream + /// Sends zatoshi. /// - Parameter spendingKey: the `UnifiedSpendingKey` that allows spends to occur. /// - Parameter zatoshi: the amount to send in Zatoshi. @@ -433,6 +479,19 @@ public enum RewindPolicy { case quick } +/// The result of submitting a transaction to the network. +/// +/// - success: the transaction was successfully submitted to the mempool. +/// - grpcFailure: the transaction failed to reach the lightwalletd server. +/// - submitFailure: the transaction reached the lightwalletd server but failed to enter the mempool. +/// - notAttempted: the transaction was created and is in the local wallet, but was not submitted to the network. +public enum TransactionSubmitResult: Equatable { + case success(txId: Data) + case grpcFailure(txId: Data, error: LightWalletServiceError) + case submitFailure(txId: Data, code: Int, description: String) + case notAttempted(txId: Data) +} + extension InternalSyncStatus { public static func == (lhs: InternalSyncStatus, rhs: InternalSyncStatus) -> Bool { switch (lhs, rhs) { diff --git a/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift index 71b33392..8db106dd 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift @@ -70,6 +70,39 @@ extension ClosureSDKSynchronizer: ClosureSynchronizer { } } + public func proposeTransfer( + accountIndex: Int, + recipient: Recipient, + amount: Zatoshi, + memo: Memo?, + completion: @escaping (Result) -> Void + ) { + AsyncToClosureGateway.executeThrowingAction(completion) { + try await self.synchronizer.proposeTransfer(accountIndex: accountIndex, recipient: recipient, amount: amount, memo: memo) + } + } + + public func proposeShielding( + accountIndex: Int, + shieldingThreshold: Zatoshi, + memo: Memo, + completion: @escaping (Result) -> Void + ) { + AsyncToClosureGateway.executeThrowingAction(completion) { + try await self.synchronizer.proposeShielding(accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, memo: memo) + } + } + + public func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey, + completion: @escaping (Result, Error>) -> Void + ) { + AsyncToClosureGateway.executeThrowingAction(completion) { + try await self.synchronizer.createProposedTransactions(proposal: proposal, spendingKey: spendingKey) + } + } + public func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, diff --git a/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift index ab3f286a..2fef6c23 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift @@ -69,6 +69,36 @@ extension CombineSDKSynchronizer: CombineSynchronizer { } } + public func proposeTransfer( + accountIndex: Int, + recipient: Recipient, + amount: Zatoshi, + memo: Memo? + ) -> SinglePublisher { + AsyncToCombineGateway.executeThrowingAction() { + try await self.synchronizer.proposeTransfer(accountIndex: accountIndex, recipient: recipient, amount: amount, memo: memo) + } + } + + public func proposeShielding( + accountIndex: Int, + shieldingThreshold: Zatoshi, + memo: Memo + ) -> SinglePublisher { + AsyncToCombineGateway.executeThrowingAction() { + try await self.synchronizer.proposeShielding(accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, memo: memo) + } + } + + public func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) -> SinglePublisher, Error> { + AsyncToCombineGateway.executeThrowingAction() { + try await self.synchronizer.createProposedTransactions(proposal: proposal, spendingKey: spendingKey) + } + } + public func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift index a6d84f77..9918d171 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift @@ -265,6 +265,78 @@ public class SDKSynchronizer: Synchronizer { // MARK: Synchronizer methods + public func proposeTransfer(accountIndex: Int, recipient: Recipient, amount: Zatoshi, memo: Memo?) async throws -> Proposal { + try throwIfUnprepared() + + if case Recipient.transparent = recipient, memo != nil { + throw ZcashError.synchronizerSendMemoToTransparentAddress + } + + let proposal = try await transactionEncoder.proposeTransfer( + accountIndex: accountIndex, + recipient: recipient.stringEncoded, + amount: amount, + memoBytes: memo?.asMemoBytes() + ) + + return proposal + } + + public func proposeShielding(accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo) async throws -> Proposal { + try throwIfUnprepared() + + let proposal = try await transactionEncoder.proposeShielding( + accountIndex: accountIndex, + shieldingThreshold: shieldingThreshold, + memoBytes: memo.asMemoBytes() + ) + + return proposal + } + + public func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) async throws -> AsyncThrowingStream { + try throwIfUnprepared() + + try await SaplingParameterDownloader.downloadParamsIfnotPresent( + spendURL: initializer.spendParamsURL, + spendSourceURL: initializer.saplingParamsSourceURL.spendParamFileURL, + outputURL: initializer.outputParamsURL, + outputSourceURL: initializer.saplingParamsSourceURL.outputParamFileURL, + logger: logger + ) + + let transactions = try await transactionEncoder.createProposedTransactions( + proposal: proposal, + spendingKey: spendingKey + ) + var iterator = transactions.makeIterator() + var submitFailed = false + + return AsyncThrowingStream() { + guard let transaction = iterator.next() else { return nil } + + if submitFailed { + return .notAttempted(txId: transaction.rawID) + } else { + let encodedTransaction = try transaction.encodedTransaction() + + do { + try await self.transactionEncoder.submit(transaction: encodedTransaction) + return TransactionSubmitResult.success(txId: transaction.rawID) + } catch ZcashError.serviceSubmitFailed(let error) { + submitFailed = true + return TransactionSubmitResult.grpcFailure(txId: transaction.rawID, error: error) + } catch TransactionEncoderError.submitError(let code, let message) { + submitFailed = true + return TransactionSubmitResult.submitFailure(txId: transaction.rawID, code: code, description: message) + } + } + } + } + public func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, @@ -312,13 +384,20 @@ public class SDKSynchronizer: Synchronizer { throw ZcashError.synchronizerShieldFundsInsuficientTransparentFunds } - let transaction = try await transactionEncoder.createShieldingTransaction( - spendingKey: spendingKey, + let proposal = try await transactionEncoder.proposeShielding( + accountIndex: Int(spendingKey.account), shieldingThreshold: shieldingThreshold, - memoBytes: memo.asMemoBytes(), - from: Int(spendingKey.account) + memoBytes: memo.asMemoBytes() ) + let transactions = try await transactionEncoder.createProposedTransactions( + proposal: proposal, + spendingKey: spendingKey + ) + + assert(transactions.count == 1, "Rust backend doesn't produce multiple transactions yet") + let transaction = transactions[0] + let encodedTx = try transaction.encodedTransaction() try await transactionEncoder.submit(transaction: encodedTx) @@ -339,14 +418,21 @@ public class SDKSynchronizer: Synchronizer { throw ZcashError.synchronizerSendMemoToTransparentAddress } - let transaction = try await transactionEncoder.createTransaction( - spendingKey: spendingKey, - zatoshi: zatoshi, - to: recipient.stringEncoded, - memoBytes: memo?.asMemoBytes(), - from: Int(spendingKey.account) + let proposal = try await transactionEncoder.proposeTransfer( + accountIndex: Int(spendingKey.account), + recipient: recipient.stringEncoded, + amount: zatoshi, + memoBytes: memo?.asMemoBytes() ) + let transactions = try await transactionEncoder.createProposedTransactions( + proposal: proposal, + spendingKey: spendingKey + ) + + assert(transactions.count == 1, "Rust backend doesn't produce multiple transactions yet") + let transaction = transactions[0] + let encodedTransaction = try transaction.encodedTransaction() try await transactionEncoder.submit(transaction: encodedTransaction) diff --git a/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift b/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift index 3aef616c..163b7450 100644 --- a/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift +++ b/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift @@ -19,44 +19,50 @@ public enum TransactionEncoderError: Error { } protocol TransactionEncoder { - /// Creates a transaction, throwing an exception whenever things are missing. When the provided wallet implementation - /// doesn't throw an exception, we wrap the issue into a descriptive exception ourselves (rather than using - /// double-bangs for things). + /// Creates a proposal for transferring funds to the given recipient. /// - /// - Parameters: - /// - Parameter spendingKey: a `UnifiedSpendingKey` containing the spending key - /// - Parameter zatoshi: the amount to send in `Zatoshi` - /// - Parameter to: string containing the recipient address - /// - Parameter MemoBytes: string containing the memo (optional) - /// - Parameter accountIndex: index of the account that will be used to send the funds + /// - Parameter accountIndex: the account from which to transfer funds. + /// - Parameter recipient: string containing the recipient's address. + /// - Parameter amount: the amount to send in Zatoshi. + /// - Parameter memoBytes: an optional memo to include as part of the proposal's transactions. Use `nil` when sending to transparent receivers otherwise the function will throw an error. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws + /// `SynchronizerErrors.notPrepared`. + func proposeTransfer( + accountIndex: Int, + recipient: String, + amount: Zatoshi, + memoBytes: MemoBytes? + ) async throws -> Proposal + + /// Creates a proposal for shielding any transparent funds received by the given account. + /// + /// - Parameter accountIndex: the account for which to shield funds. + /// - Parameter shieldingThreshold: the minimum transparent balance required before a proposal will be created. + /// - Parameter memoBytes: an optional memo to include as part of the proposal's transactions. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws + /// `SynchronizerErrors.notPrepared`. + func proposeShielding( + accountIndex: Int, + shieldingThreshold: Zatoshi, + memoBytes: MemoBytes? + ) async throws -> Proposal + + /// Creates the transactions in the given proposal. + /// + /// - Parameter proposal: the proposal for which to create transactions. + /// - Parameter spendingKey: the `UnifiedSpendingKey` associated with the account for which the proposal was created. /// - Throws: /// - `walletTransEncoderCreateTransactionMissingSaplingParams` if the sapling parameters aren't downloaded. - /// - Some `ZcashError.rust*` if the creation of transaction fails. - func createTransaction( - spendingKey: UnifiedSpendingKey, - zatoshi: Zatoshi, - to address: String, - memoBytes: MemoBytes?, - from accountIndex: Int - ) async throws -> ZcashTransaction.Overview - - /// Creates a transaction that will attempt to shield transparent funds that are present on the blocks cache .throwing - /// an exception whenever things are missing. When the provided wallet implementation doesn't throw an exception, - /// we wrap the issue into a descriptive exception ourselves (rather than using double-bangs for things). + /// - Some `ZcashError.rust*` if the creation of transaction(s) fails. /// - /// - Parameters: - /// - Parameter spendingKey: `UnifiedSpendingKey` to spend the UTXOs - /// - Parameter memoBytes: containing the memo (optional) - /// - Parameter accountIndex: index of the account that will be used to send the funds - /// - Throws: - /// - `walletTransEncoderShieldFundsMissingSaplingParams` if the sapling parameters aren't downloaded. - /// - Some `ZcashError.rust*` if the creation of transaction fails. - func createShieldingTransaction( - spendingKey: UnifiedSpendingKey, - shieldingThreshold: Zatoshi, - memoBytes: MemoBytes?, - from accountIndex: Int - ) async throws -> ZcashTransaction.Overview + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws + /// `SynchronizerErrors.notPrepared`. + func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) async throws -> [ZcashTransaction.Overview] /// submits a transaction to the Zcash peer-to-peer network. /// - Parameter transaction: a transaction overview diff --git a/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift b/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift index f24927bd..fb4c2c1b 100644 --- a/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift +++ b/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift @@ -54,93 +54,52 @@ class WalletTransactionEncoder: TransactionEncoder { logger: initializer.logger ) } - - func createTransaction( - spendingKey: UnifiedSpendingKey, - zatoshi: Zatoshi, - to address: String, - memoBytes: MemoBytes?, - from accountIndex: Int - ) async throws -> ZcashTransaction.Overview { - let txId = try await createSpend( - spendingKey: spendingKey, - zatoshi: zatoshi, - to: address, - memoBytes: memoBytes, - from: accountIndex + + func proposeTransfer( + accountIndex: Int, + recipient: String, + amount: Zatoshi, + memoBytes: MemoBytes? + ) async throws -> Proposal { + let proposal = try await rustBackend.proposeTransfer( + account: Int32(accountIndex), + to: recipient, + value: amount.amount, + memo: memoBytes ) - logger.debug("transaction id: \(txId)") - return try await repository.find(rawID: txId) + return Proposal(inner: proposal) } - - func createSpend( - spendingKey: UnifiedSpendingKey, - zatoshi: Zatoshi, - to address: String, - memoBytes: MemoBytes?, - from accountIndex: Int - ) async throws -> Data { + + func proposeShielding( + accountIndex: Int, + shieldingThreshold: Zatoshi, + memoBytes: MemoBytes? + ) async throws -> Proposal { + let proposal = try await rustBackend.proposeShielding( + account: Int32(accountIndex), + memo: memoBytes, + shieldingThreshold: shieldingThreshold + ) + + return Proposal(inner: proposal) + } + + func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) async throws -> [ZcashTransaction.Overview] { guard ensureParams(spend: self.spendParamsURL, output: self.outputParamsURL) else { throw ZcashError.walletTransEncoderCreateTransactionMissingSaplingParams } - // TODO: Expose the proposal in a way that enables querying its fee. - let proposal = try await rustBackend.proposeTransfer( - account: Int32(spendingKey.account), - to: address, - value: zatoshi.amount, - memo: memoBytes - ) - let txId = try await rustBackend.createProposedTransaction( - proposal: proposal, + proposal: proposal.inner, usk: spendingKey ) - return txId - } - - func createShieldingTransaction( - spendingKey: UnifiedSpendingKey, - shieldingThreshold: Zatoshi, - memoBytes: MemoBytes?, - from accountIndex: Int - ) async throws -> ZcashTransaction.Overview { - let txId = try await createShieldingSpend( - spendingKey: spendingKey, - shieldingThreshold: shieldingThreshold, - memo: memoBytes, - accountIndex: accountIndex - ) - logger.debug("transaction id: \(txId)") - return try await repository.find(rawID: txId) - } - - func createShieldingSpend( - spendingKey: UnifiedSpendingKey, - shieldingThreshold: Zatoshi, - memo: MemoBytes?, - accountIndex: Int - ) async throws -> Data { - guard ensureParams(spend: self.spendParamsURL, output: self.outputParamsURL) else { - throw ZcashError.walletTransEncoderShieldFundsMissingSaplingParams - } - - // TODO: Expose the proposal in a way that enables querying its fee. - let proposal = try await rustBackend.proposeShielding( - account: Int32(spendingKey.account), - memo: memo, - shieldingThreshold: shieldingThreshold - ) - - let txId = try await rustBackend.createProposedTransaction( - proposal: proposal, - usk: spendingKey - ) - - return txId + return [try await repository.find(rawID: txId)] } func submit( diff --git a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift index afbe16a6..cc2049da 100644 --- a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift +++ b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift @@ -1397,6 +1397,78 @@ class SynchronizerMock: Synchronizer { } } + // MARK: - proposeTransfer + + var proposeTransferAccountIndexRecipientAmountMemoThrowableError: Error? + var proposeTransferAccountIndexRecipientAmountMemoCallsCount = 0 + var proposeTransferAccountIndexRecipientAmountMemoCalled: Bool { + return proposeTransferAccountIndexRecipientAmountMemoCallsCount > 0 + } + var proposeTransferAccountIndexRecipientAmountMemoReceivedArguments: (accountIndex: Int, recipient: Recipient, amount: Zatoshi, memo: Memo?)? + var proposeTransferAccountIndexRecipientAmountMemoReturnValue: Proposal! + var proposeTransferAccountIndexRecipientAmountMemoClosure: ((Int, Recipient, Zatoshi, Memo?) async throws -> Proposal)? + + func proposeTransfer(accountIndex: Int, recipient: Recipient, amount: Zatoshi, memo: Memo?) async throws -> Proposal { + if let error = proposeTransferAccountIndexRecipientAmountMemoThrowableError { + throw error + } + proposeTransferAccountIndexRecipientAmountMemoCallsCount += 1 + proposeTransferAccountIndexRecipientAmountMemoReceivedArguments = (accountIndex: accountIndex, recipient: recipient, amount: amount, memo: memo) + if let closure = proposeTransferAccountIndexRecipientAmountMemoClosure { + return try await closure(accountIndex, recipient, amount, memo) + } else { + return proposeTransferAccountIndexRecipientAmountMemoReturnValue + } + } + + // MARK: - proposeShielding + + var proposeShieldingAccountIndexShieldingThresholdMemoThrowableError: Error? + var proposeShieldingAccountIndexShieldingThresholdMemoCallsCount = 0 + var proposeShieldingAccountIndexShieldingThresholdMemoCalled: Bool { + return proposeShieldingAccountIndexShieldingThresholdMemoCallsCount > 0 + } + var proposeShieldingAccountIndexShieldingThresholdMemoReceivedArguments: (accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo)? + var proposeShieldingAccountIndexShieldingThresholdMemoReturnValue: Proposal! + var proposeShieldingAccountIndexShieldingThresholdMemoClosure: ((Int, Zatoshi, Memo) async throws -> Proposal)? + + func proposeShielding(accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo) async throws -> Proposal { + if let error = proposeShieldingAccountIndexShieldingThresholdMemoThrowableError { + throw error + } + proposeShieldingAccountIndexShieldingThresholdMemoCallsCount += 1 + proposeShieldingAccountIndexShieldingThresholdMemoReceivedArguments = (accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, memo: memo) + if let closure = proposeShieldingAccountIndexShieldingThresholdMemoClosure { + return try await closure(accountIndex, shieldingThreshold, memo) + } else { + return proposeShieldingAccountIndexShieldingThresholdMemoReturnValue + } + } + + // MARK: - createProposedTransactions + + var createProposedTransactionsProposalSpendingKeyThrowableError: Error? + var createProposedTransactionsProposalSpendingKeyCallsCount = 0 + var createProposedTransactionsProposalSpendingKeyCalled: Bool { + return createProposedTransactionsProposalSpendingKeyCallsCount > 0 + } + var createProposedTransactionsProposalSpendingKeyReceivedArguments: (proposal: Proposal, spendingKey: UnifiedSpendingKey)? + var createProposedTransactionsProposalSpendingKeyReturnValue: AsyncThrowingStream! + var createProposedTransactionsProposalSpendingKeyClosure: ((Proposal, UnifiedSpendingKey) async throws -> AsyncThrowingStream)? + + func createProposedTransactions(proposal: Proposal, spendingKey: UnifiedSpendingKey) async throws -> AsyncThrowingStream { + if let error = createProposedTransactionsProposalSpendingKeyThrowableError { + throw error + } + createProposedTransactionsProposalSpendingKeyCallsCount += 1 + createProposedTransactionsProposalSpendingKeyReceivedArguments = (proposal: proposal, spendingKey: spendingKey) + if let closure = createProposedTransactionsProposalSpendingKeyClosure { + return try await closure(proposal, spendingKey) + } else { + return createProposedTransactionsProposalSpendingKeyReturnValue + } + } + // MARK: - sendToAddress var sendToAddressSpendingKeyZatoshiToAddressMemoThrowableError: Error? From dd41e655e49a87aaa8b9a4c54f4c466b096eaec5 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 21 Feb 2024 01:15:01 +0000 Subject: [PATCH 02/10] Deprecate `Synchronizer.sendToAddress` and `Synchronizer.shieldFunds` --- CHANGELOG.md | 4 ++++ Sources/ZcashLightClientKit/Synchronizer.swift | 2 ++ .../Synchronizer/ClosureSDKSynchronizer.swift | 2 ++ .../Synchronizer/CombineSDKSynchronizer.swift | 2 ++ 4 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e87280c..947fba7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ shielding funds, and then creating transactions from a proposal. The intermediat proposal can be used to determine the required fee, before committing to producing transactions. +The old `Synchronizer.sendToAddress` and `Synchronizer.shieldFunds` APIs have been +deprecated, and will be removed in 2.1.0 (which will create multiple transactions +at once for some recipients). + # 2.0.10 - 2024-02-12 ## Added diff --git a/Sources/ZcashLightClientKit/Synchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer.swift index 01158640..bea172cc 100644 --- a/Sources/ZcashLightClientKit/Synchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer.swift @@ -209,6 +209,7 @@ public protocol Synchronizer: AnyObject { /// /// If `prepare()` hasn't already been called since creating of synchronizer instance or since the last wipe then this method throws /// `SynchronizerErrors.notPrepared`. + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, @@ -222,6 +223,7 @@ public protocol Synchronizer: AnyObject { /// /// If `prepare()` hasn't already been called since creating of synchronizer instance or since the last wipe then this method throws /// `SynchronizerErrors.notPrepared`. + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") func shieldFunds( spendingKey: UnifiedSpendingKey, memo: Memo, diff --git a/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift index 8db106dd..086b47e1 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift @@ -103,6 +103,7 @@ extension ClosureSDKSynchronizer: ClosureSynchronizer { } } + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") public func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, @@ -115,6 +116,7 @@ extension ClosureSDKSynchronizer: ClosureSynchronizer { } } + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") public func shieldFunds( spendingKey: UnifiedSpendingKey, memo: Memo, diff --git a/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift index 2fef6c23..e30625a0 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift @@ -99,6 +99,7 @@ extension CombineSDKSynchronizer: CombineSynchronizer { } } + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") public func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, @@ -110,6 +111,7 @@ extension CombineSDKSynchronizer: CombineSynchronizer { } } + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") public func shieldFunds( spendingKey: UnifiedSpendingKey, memo: Memo, From 136a3116b90f16c43e2e6a20b48c90bc20cc6be8 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Tue, 27 Feb 2024 16:03:24 +0000 Subject: [PATCH 03/10] Documentation fixes --- Sources/ZcashLightClientKit/Synchronizer.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/ZcashLightClientKit/Synchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer.swift index bea172cc..639fce98 100644 --- a/Sources/ZcashLightClientKit/Synchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer.swift @@ -207,7 +207,7 @@ public protocol Synchronizer: AnyObject { /// - Parameter toAddress: the recipient's address. /// - Parameter memo: an `Optional`with the memo to include as part of the transaction. send `nil` when sending to transparent receivers otherwise the function will throw an error /// - /// If `prepare()` hasn't already been called since creating of synchronizer instance or since the last wipe then this method throws + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws /// `SynchronizerErrors.notPrepared`. @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") func sendToAddress( @@ -220,8 +220,9 @@ public protocol Synchronizer: AnyObject { /// Shields transparent funds from the given private key into the best shielded pool of the account associated to the given `UnifiedSpendingKey`. /// - Parameter spendingKey: the `UnifiedSpendingKey` that allows to spend transparent funds /// - Parameter memo: the optional memo to include as part of the transaction. + /// - Parameter shieldingThreshold: the minimum transparent balance required before a transaction will be created. /// - /// If `prepare()` hasn't already been called since creating of synchronizer instance or since the last wipe then this method throws + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws /// `SynchronizerErrors.notPrepared`. @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") func shieldFunds( @@ -275,7 +276,7 @@ public protocol Synchronizer: AnyObject { /// Returns the latests UTXOs for the given address from the specified height on /// - /// If `prepare()` hasn't already been called since creating of synchronizer instance or since the last wipe then this method throws + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws /// `SynchronizerErrors.notPrepared`. func refreshUTXOs(address: TransparentAddress, from height: BlockHeight) async throws -> RefreshedUTXOs @@ -299,7 +300,7 @@ public protocol Synchronizer: AnyObject { /// `rewind(policy:)` itself doesn't start the sync process when it's done and it doesn't trigger notifications as regorg would. After it is done /// you have start the sync process by calling `start()` /// - /// If `prepare()` hasn't already been called since creating of synchronizer instance or since the last wipe then returned publisher emits + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then returned publisher emits /// `SynchronizerErrors.notPrepared` error. /// /// - Parameter policy: the rewind policy From 84ac6252feb88b9dbf9865651df3bc478225f5a1 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 28 Feb 2024 16:23:12 +0000 Subject: [PATCH 04/10] Adjust `Synchronizer.proposeShielding` API - Returns `null` when there are no funds to shield or the shielding threshold is not met. - Throws an exception if there are funds to shield in more than one transparent receiver within the account. - Has an optional parameter for specifying which transparent receiver to shield funds from. This commit only alters the API to support the above; the functional changes require modifying the FFI and Rust backend, which will happen in a separate commit. --- .../Rust/ZcashRustBackend.swift | 9 ++- .../Rust/ZcashRustBackendWelding.swift | 12 +++- .../ZcashLightClientKit/Synchronizer.swift | 12 +++- .../Synchronizer/ClosureSDKSynchronizer.swift | 10 ++- .../Synchronizer/CombineSDKSynchronizer.swift | 12 +++- .../Synchronizer/SDKSynchronizer.swift | 17 +++-- .../Transaction/TransactionEncoder.swift | 12 +++- .../WalletTransactionEncoder.swift | 12 ++-- .../AutoMockable.generated.swift | 68 +++++++++---------- Tests/TestUtils/Stubs.swift | 2 +- 10 files changed, 108 insertions(+), 58 deletions(-) diff --git a/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift b/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift index 100c0944..99d128a6 100644 --- a/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift +++ b/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift @@ -619,8 +619,13 @@ actor ZcashRustBackend: ZcashRustBackendWelding { func proposeShielding( account: Int32, memo: MemoBytes?, - shieldingThreshold: Zatoshi - ) async throws -> FfiProposal { + shieldingThreshold: Zatoshi, + transparentReceiver: String? + ) async throws -> FfiProposal? { + if transparentReceiver != nil { + throw ZcashError.rustScanBlocks("TODO: Implement transparentReceiver support in FFI") + } + globalDBLock.lock() let proposal = zcashlc_propose_shielding( dbData.0, diff --git a/Sources/ZcashLightClientKit/Rust/ZcashRustBackendWelding.swift b/Sources/ZcashLightClientKit/Rust/ZcashRustBackendWelding.swift index 4d30e496..fd6cc03f 100644 --- a/Sources/ZcashLightClientKit/Rust/ZcashRustBackendWelding.swift +++ b/Sources/ZcashLightClientKit/Rust/ZcashRustBackendWelding.swift @@ -212,14 +212,22 @@ protocol ZcashRustBackendWelding { /// that can then be authorized and made ready for submission to the network with /// `createProposedTransaction`. /// + /// Returns the proposal, or `nil` if the transparent balance that would be shielded + /// is zero or below `shieldingThreshold`. + /// /// - parameter account: index of the given account /// - Parameter memo: the `Memo` for this transaction + /// - Parameter transparentReceiver: a specific transparent receiver within the account + /// that should be the source of transparent funds. Default is `nil` which + /// will select whichever of the account's transparent receivers has funds + /// to shield. /// - Throws: `rustShieldFunds` if rust layer returns error. func proposeShielding( account: Int32, memo: MemoBytes?, - shieldingThreshold: Zatoshi - ) async throws -> FfiProposal + shieldingThreshold: Zatoshi, + transparentReceiver: String? + ) async throws -> FfiProposal? /// Creates a transaction from the given proposal. /// - Parameter proposal: the transaction proposal. diff --git a/Sources/ZcashLightClientKit/Synchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer.swift index 639fce98..e94013de 100644 --- a/Sources/ZcashLightClientKit/Synchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer.swift @@ -176,14 +176,22 @@ public protocol Synchronizer: AnyObject { /// - Parameter accountIndex: the account for which to shield funds. /// - Parameter shieldingThreshold: the minimum transparent balance required before a proposal will be created. /// - Parameter memo: an optional memo to include as part of the proposal's transactions. + /// - Parameter transparentReceiver: a specific transparent receiver within the account + /// that should be the source of transparent funds. Default is `nil` which + /// will select whichever of the account's transparent receivers has funds + /// to shield. + /// + /// Returns the proposal, or `nil` if the transparent balance that would be shielded + /// is zero or below `shieldingThreshold`. /// /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws /// `SynchronizerErrors.notPrepared`. func proposeShielding( accountIndex: Int, shieldingThreshold: Zatoshi, - memo: Memo - ) async throws -> Proposal + memo: Memo, + transparentReceiver: TransparentAddress? + ) async throws -> Proposal? /// Creates the transactions in the given proposal. /// diff --git a/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift index 086b47e1..1f2d31ae 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift @@ -86,10 +86,16 @@ extension ClosureSDKSynchronizer: ClosureSynchronizer { accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo, - completion: @escaping (Result) -> Void + transparentReceiver: TransparentAddress? = nil, + completion: @escaping (Result) -> Void ) { AsyncToClosureGateway.executeThrowingAction(completion) { - try await self.synchronizer.proposeShielding(accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, memo: memo) + try await self.synchronizer.proposeShielding( + accountIndex: accountIndex, + shieldingThreshold: shieldingThreshold, + memo: memo, + transparentReceiver: transparentReceiver + ) } } diff --git a/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift index e30625a0..aa38be92 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift @@ -83,10 +83,16 @@ extension CombineSDKSynchronizer: CombineSynchronizer { public func proposeShielding( accountIndex: Int, shieldingThreshold: Zatoshi, - memo: Memo - ) -> SinglePublisher { + memo: Memo, + transparentReceiver: TransparentAddress? = nil + ) -> SinglePublisher { AsyncToCombineGateway.executeThrowingAction() { - try await self.synchronizer.proposeShielding(accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, memo: memo) + try await self.synchronizer.proposeShielding( + accountIndex: accountIndex, + shieldingThreshold: shieldingThreshold, + memo: memo, + transparentReceiver: transparentReceiver + ) } } diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift index 9918d171..a162a872 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift @@ -282,13 +282,19 @@ public class SDKSynchronizer: Synchronizer { return proposal } - public func proposeShielding(accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo) async throws -> Proposal { + public func proposeShielding( + accountIndex: Int, + shieldingThreshold: Zatoshi, + memo: Memo, + transparentReceiver: TransparentAddress? = nil + ) async throws -> Proposal? { try throwIfUnprepared() let proposal = try await transactionEncoder.proposeShielding( accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, - memoBytes: memo.asMemoBytes() + memoBytes: memo.asMemoBytes(), + transparentReceiver: transparentReceiver?.stringEncoded ) return proposal @@ -384,11 +390,12 @@ public class SDKSynchronizer: Synchronizer { throw ZcashError.synchronizerShieldFundsInsuficientTransparentFunds } - let proposal = try await transactionEncoder.proposeShielding( + guard let proposal = try await transactionEncoder.proposeShielding( accountIndex: Int(spendingKey.account), shieldingThreshold: shieldingThreshold, - memoBytes: memo.asMemoBytes() - ) + memoBytes: memo.asMemoBytes(), + transparentReceiver: nil + ) else { throw ZcashError.synchronizerShieldFundsInsuficientTransparentFunds } let transactions = try await transactionEncoder.createProposedTransactions( proposal: proposal, diff --git a/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift b/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift index 163b7450..6acfef78 100644 --- a/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift +++ b/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift @@ -40,14 +40,22 @@ protocol TransactionEncoder { /// - Parameter accountIndex: the account for which to shield funds. /// - Parameter shieldingThreshold: the minimum transparent balance required before a proposal will be created. /// - Parameter memoBytes: an optional memo to include as part of the proposal's transactions. + /// - Parameter transparentReceiver: a specific transparent receiver within the account + /// that should be the source of transparent funds. Default is `nil` which + /// will select whichever of the account's transparent receivers has funds + /// to shield. + /// + /// Returns the proposal, or `nil` if the transparent balance that would be shielded + /// is zero or below `shieldingThreshold`. /// /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws /// `SynchronizerErrors.notPrepared`. func proposeShielding( accountIndex: Int, shieldingThreshold: Zatoshi, - memoBytes: MemoBytes? - ) async throws -> Proposal + memoBytes: MemoBytes?, + transparentReceiver: String? + ) async throws -> Proposal? /// Creates the transactions in the given proposal. /// diff --git a/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift b/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift index fb4c2c1b..5e9ed9bd 100644 --- a/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift +++ b/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift @@ -74,13 +74,15 @@ class WalletTransactionEncoder: TransactionEncoder { func proposeShielding( accountIndex: Int, shieldingThreshold: Zatoshi, - memoBytes: MemoBytes? - ) async throws -> Proposal { - let proposal = try await rustBackend.proposeShielding( + memoBytes: MemoBytes?, + transparentReceiver: String? = nil + ) async throws -> Proposal? { + guard let proposal = try await rustBackend.proposeShielding( account: Int32(accountIndex), memo: memoBytes, - shieldingThreshold: shieldingThreshold - ) + shieldingThreshold: shieldingThreshold, + transparentReceiver: transparentReceiver + ) else { return nil } return Proposal(inner: proposal) } diff --git a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift index cc2049da..ebb863bc 100644 --- a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift +++ b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift @@ -1423,25 +1423,25 @@ class SynchronizerMock: Synchronizer { // MARK: - proposeShielding - var proposeShieldingAccountIndexShieldingThresholdMemoThrowableError: Error? - var proposeShieldingAccountIndexShieldingThresholdMemoCallsCount = 0 - var proposeShieldingAccountIndexShieldingThresholdMemoCalled: Bool { - return proposeShieldingAccountIndexShieldingThresholdMemoCallsCount > 0 + var proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverThrowableError: Error? + var proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverCallsCount = 0 + var proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverCalled: Bool { + return proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverCallsCount > 0 } - var proposeShieldingAccountIndexShieldingThresholdMemoReceivedArguments: (accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo)? - var proposeShieldingAccountIndexShieldingThresholdMemoReturnValue: Proposal! - var proposeShieldingAccountIndexShieldingThresholdMemoClosure: ((Int, Zatoshi, Memo) async throws -> Proposal)? + var proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverReceivedArguments: (accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo, transparentReceiver: TransparentAddress?)? + var proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverReturnValue: Proposal? + var proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverClosure: ((Int, Zatoshi, Memo, TransparentAddress?) async throws -> Proposal?)? - func proposeShielding(accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo) async throws -> Proposal { - if let error = proposeShieldingAccountIndexShieldingThresholdMemoThrowableError { + func proposeShielding(accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo, transparentReceiver: TransparentAddress?) async throws -> Proposal? { + if let error = proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverThrowableError { throw error } - proposeShieldingAccountIndexShieldingThresholdMemoCallsCount += 1 - proposeShieldingAccountIndexShieldingThresholdMemoReceivedArguments = (accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, memo: memo) - if let closure = proposeShieldingAccountIndexShieldingThresholdMemoClosure { - return try await closure(accountIndex, shieldingThreshold, memo) + proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverCallsCount += 1 + proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverReceivedArguments = (accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, memo: memo, transparentReceiver: transparentReceiver) + if let closure = proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverClosure { + return try await closure(accountIndex, shieldingThreshold, memo, transparentReceiver) } else { - return proposeShieldingAccountIndexShieldingThresholdMemoReturnValue + return proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverReturnValue } } @@ -2828,34 +2828,34 @@ actor ZcashRustBackendWeldingMock: ZcashRustBackendWelding { // MARK: - proposeShielding - var proposeShieldingAccountMemoShieldingThresholdThrowableError: Error? - func setProposeShieldingAccountMemoShieldingThresholdThrowableError(_ param: Error?) async { - proposeShieldingAccountMemoShieldingThresholdThrowableError = param + var proposeShieldingAccountMemoShieldingThresholdTransparentReceiverThrowableError: Error? + func setProposeShieldingAccountMemoShieldingThresholdTransparentReceiverThrowableError(_ param: Error?) async { + proposeShieldingAccountMemoShieldingThresholdTransparentReceiverThrowableError = param } - var proposeShieldingAccountMemoShieldingThresholdCallsCount = 0 - var proposeShieldingAccountMemoShieldingThresholdCalled: Bool { - return proposeShieldingAccountMemoShieldingThresholdCallsCount > 0 + var proposeShieldingAccountMemoShieldingThresholdTransparentReceiverCallsCount = 0 + var proposeShieldingAccountMemoShieldingThresholdTransparentReceiverCalled: Bool { + return proposeShieldingAccountMemoShieldingThresholdTransparentReceiverCallsCount > 0 } - var proposeShieldingAccountMemoShieldingThresholdReceivedArguments: (account: Int32, memo: MemoBytes?, shieldingThreshold: Zatoshi)? - var proposeShieldingAccountMemoShieldingThresholdReturnValue: FfiProposal! - func setProposeShieldingAccountMemoShieldingThresholdReturnValue(_ param: FfiProposal) async { - proposeShieldingAccountMemoShieldingThresholdReturnValue = param + var proposeShieldingAccountMemoShieldingThresholdTransparentReceiverReceivedArguments: (account: Int32, memo: MemoBytes?, shieldingThreshold: Zatoshi, transparentReceiver: String?)? + var proposeShieldingAccountMemoShieldingThresholdTransparentReceiverReturnValue: FfiProposal? + func setProposeShieldingAccountMemoShieldingThresholdTransparentReceiverReturnValue(_ param: FfiProposal?) async { + proposeShieldingAccountMemoShieldingThresholdTransparentReceiverReturnValue = param } - var proposeShieldingAccountMemoShieldingThresholdClosure: ((Int32, MemoBytes?, Zatoshi) async throws -> FfiProposal)? - func setProposeShieldingAccountMemoShieldingThresholdClosure(_ param: ((Int32, MemoBytes?, Zatoshi) async throws -> FfiProposal)?) async { - proposeShieldingAccountMemoShieldingThresholdClosure = param + var proposeShieldingAccountMemoShieldingThresholdTransparentReceiverClosure: ((Int32, MemoBytes?, Zatoshi, String?) async throws -> FfiProposal?)? + func setProposeShieldingAccountMemoShieldingThresholdTransparentReceiverClosure(_ param: ((Int32, MemoBytes?, Zatoshi, String?) async throws -> FfiProposal?)?) async { + proposeShieldingAccountMemoShieldingThresholdTransparentReceiverClosure = param } - func proposeShielding(account: Int32, memo: MemoBytes?, shieldingThreshold: Zatoshi) async throws -> FfiProposal { - if let error = proposeShieldingAccountMemoShieldingThresholdThrowableError { + func proposeShielding(account: Int32, memo: MemoBytes?, shieldingThreshold: Zatoshi, transparentReceiver: String?) async throws -> FfiProposal? { + if let error = proposeShieldingAccountMemoShieldingThresholdTransparentReceiverThrowableError { throw error } - proposeShieldingAccountMemoShieldingThresholdCallsCount += 1 - proposeShieldingAccountMemoShieldingThresholdReceivedArguments = (account: account, memo: memo, shieldingThreshold: shieldingThreshold) - if let closure = proposeShieldingAccountMemoShieldingThresholdClosure { - return try await closure(account, memo, shieldingThreshold) + proposeShieldingAccountMemoShieldingThresholdTransparentReceiverCallsCount += 1 + proposeShieldingAccountMemoShieldingThresholdTransparentReceiverReceivedArguments = (account: account, memo: memo, shieldingThreshold: shieldingThreshold, transparentReceiver: transparentReceiver) + if let closure = proposeShieldingAccountMemoShieldingThresholdTransparentReceiverClosure { + return try await closure(account, memo, shieldingThreshold, transparentReceiver) } else { - return proposeShieldingAccountMemoShieldingThresholdReturnValue + return proposeShieldingAccountMemoShieldingThresholdTransparentReceiverReturnValue } } diff --git a/Tests/TestUtils/Stubs.swift b/Tests/TestUtils/Stubs.swift index 660aac76..dbc62987 100644 --- a/Tests/TestUtils/Stubs.swift +++ b/Tests/TestUtils/Stubs.swift @@ -84,7 +84,7 @@ class RustBackendMockHelper { await rustBackendMock.setGetNearestRewindHeightHeightReturnValue(-1) await rustBackendMock.setPutUnspentTransparentOutputTxidIndexScriptValueHeightClosure() { _, _, _, _, _ in } await rustBackendMock.setProposeTransferAccountToValueMemoThrowableError(ZcashError.rustCreateToAddress("mocked error")) - await rustBackendMock.setProposeShieldingAccountMemoShieldingThresholdThrowableError(ZcashError.rustShieldFunds("mocked error")) + await rustBackendMock.setProposeShieldingAccountMemoShieldingThresholdTransparentReceiverThrowableError(ZcashError.rustShieldFunds("mocked error")) await rustBackendMock.setCreateProposedTransactionProposalUskThrowableError(ZcashError.rustCreateToAddress("mocked error")) await rustBackendMock.setDecryptAndStoreTransactionTxBytesMinedHeightThrowableError(ZcashError.rustDecryptAndStoreTransaction("mock fail")) From bb1a05ef5ca4cfc12815e4e1eab3f53e90ba47f3 Mon Sep 17 00:00:00 2001 From: Lukas Korba Date: Thu, 29 Feb 2024 13:31:27 +0100 Subject: [PATCH 05/10] Enable ZIP 317 fees Closes Electric-Coin-Company/zcash-swift-wallet-sdk#1186. --- CHANGELOG.md | 7 +++++++ Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 947fba7a..4e6251f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased +## Changed + +### [#1186] Enable ZIP 317 fees +- The SDK now generates transactions using [ZIP 317](https://zips.z.cash/zip-0317) fees, + instead of a fixed fee of 10,000 Zatoshi. Use `Proposal.totalFeeRequired` to check the + total fee for a transfer before creating it. + ## Added ### [#1204] Expose APIs for working with transaction proposals diff --git a/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift b/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift index 99d128a6..290bce63 100644 --- a/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift +++ b/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift @@ -13,7 +13,7 @@ let globalDBLock = NSLock() actor ZcashRustBackend: ZcashRustBackendWelding { let minimumConfirmations: UInt32 = 10 - let useZIP317Fees = false + let useZIP317Fees = true let dbData: (String, UInt) let fsBlockDbRoot: (String, UInt) From f617f17e2ab8109dddbb53cfd2050d9c8a012e85 Mon Sep 17 00:00:00 2001 From: Lukas Korba Date: Thu, 29 Feb 2024 13:31:27 +0100 Subject: [PATCH 06/10] Add testOnlyFakeProposal for testing purposes outside SDK --- Sources/ZcashLightClientKit/Model/Proposal.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Sources/ZcashLightClientKit/Model/Proposal.swift b/Sources/ZcashLightClientKit/Model/Proposal.swift index a2645c8a..eb6ba8b2 100644 --- a/Sources/ZcashLightClientKit/Model/Proposal.swift +++ b/Sources/ZcashLightClientKit/Model/Proposal.swift @@ -16,3 +16,18 @@ public struct Proposal: Equatable { Zatoshi(Int64(inner.balance.feeRequired)) } } + +public extension Proposal { + /// IMPORTANT: This function is for testing purposes only. It produces fake invalid + /// data that can be used to check UI elements, but will always produce an error when + /// passed to `Synchronizer.createProposedTransactions`. It should never be called in + /// production code. + static func testOnlyFakeProposal(totalFee: UInt64) -> Self { + var ffiProposal = FfiProposal() + var balance = FfiTransactionBalance() + + balance.feeRequired = totalFee + + return Self(inner: ffiProposal) + } +} From e9177a28f73d19f81849108e9b4978a10a08a582 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 6 Mar 2024 03:06:57 +0000 Subject: [PATCH 07/10] Migrate to in-progress version of FFI backend 0.6.0 Includes: - Multi-step transaction proposals. - Changes to support `Synchronizer.proposeShielding` API changes. --- .../xcshareddata/swiftpm/Package.resolved | 3 +- Package.resolved | 3 +- Package.swift | 3 +- .../ZcashLightClientKit/Model/Proposal.swift | 4 +- .../Service/GRPC/ProtoBuf/proposal.pb.swift | 472 ++++++++++++++++-- .../GRPC/ProtoBuf/proto/proposal.proto | 75 ++- .../Rust/ZcashRustBackend.swift | 66 +-- .../Rust/ZcashRustBackendWelding.swift | 4 +- .../WalletTransactionEncoder.swift | 13 +- .../AutoMockable.generated.swift | 40 +- Tests/TestUtils/Stubs.swift | 2 +- 11 files changed, 567 insertions(+), 118 deletions(-) diff --git a/Example/ZcashLightClientSample/ZcashLightClientSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/ZcashLightClientSample/ZcashLightClientSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 03537a49..145b2a4b 100644 --- a/Example/ZcashLightClientSample/ZcashLightClientSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/ZcashLightClientSample/ZcashLightClientSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -176,8 +176,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/zcash-hackworks/zcash-light-client-ffi", "state" : { - "revision" : "c90afd6cc092468e71810bc715ddb49be8210b75", - "version" : "0.5.1" + "revision" : "789d0c068fb32e2ab149cdd785f16e0ac88f3594" } } ], diff --git a/Package.resolved b/Package.resolved index 8b8c4e8a..7cefa11b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -122,8 +122,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/zcash-hackworks/zcash-light-client-ffi", "state" : { - "revision" : "c90afd6cc092468e71810bc715ddb49be8210b75", - "version" : "0.5.1" + "revision" : "789d0c068fb32e2ab149cdd785f16e0ac88f3594" } } ], diff --git a/Package.swift b/Package.swift index 4e410e85..dd4795c2 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.19.1"), .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.14.1"), - .package(url: "https://github.com/zcash-hackworks/zcash-light-client-ffi", exact: "0.5.1") + // Compiled from revision `97e09ed3709ae9f26226587bec852a725bc783a4`. + .package(url: "https://github.com/zcash-hackworks/zcash-light-client-ffi", revision: "789d0c068fb32e2ab149cdd785f16e0ac88f3594") ], targets: [ .target( diff --git a/Sources/ZcashLightClientKit/Model/Proposal.swift b/Sources/ZcashLightClientKit/Model/Proposal.swift index eb6ba8b2..6a238f66 100644 --- a/Sources/ZcashLightClientKit/Model/Proposal.swift +++ b/Sources/ZcashLightClientKit/Model/Proposal.swift @@ -13,7 +13,9 @@ public struct Proposal: Equatable { /// Returns the total fee to be paid across all proposed transactions, in zatoshis. public func totalFeeRequired() -> Zatoshi { - Zatoshi(Int64(inner.balance.feeRequired)) + inner.steps.reduce(Zatoshi.zero) { acc, step in + acc + Zatoshi(Int64(step.balance.feeRequired)) + } } } diff --git a/Sources/ZcashLightClientKit/Modules/Service/GRPC/ProtoBuf/proposal.pb.swift b/Sources/ZcashLightClientKit/Modules/Service/GRPC/ProtoBuf/proposal.pb.swift index 3a7e3701..3a6bf464 100644 --- a/Sources/ZcashLightClientKit/Modules/Service/GRPC/ProtoBuf/proposal.pb.swift +++ b/Sources/ZcashLightClientKit/Modules/Service/GRPC/ProtoBuf/proposal.pb.swift @@ -143,18 +143,46 @@ extension FfiFeeRule: CaseIterable { #endif // swift(>=4.2) -/// A data structure that describes the inputs to be consumed and outputs to -/// be produced in a proposed transaction. +/// A data structure that describes a series of transactions to be created. struct FfiProposal { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. + /// The version of this serialization format. var protoVersion: UInt32 = 0 + /// The fee rule used in constructing this proposal + var feeRule: FfiFeeRule = .notSpecified + + /// The target height for which the proposal was constructed + /// + /// The chain must contain at least this many blocks in order for the proposal to + /// be executed. + var minTargetHeight: UInt32 = 0 + + /// The series of transactions to be created. + var steps: [FfiProposalStep] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +/// A data structure that describes the inputs to be consumed and outputs to +/// be produced in a proposed transaction. +struct FfiProposalStep { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + /// ZIP 321 serialized transaction request var transactionRequest: String = String() + /// The vector of selected payment index / output pool mappings. Payment index + /// 0 corresponds to the payment with no explicit index. + var paymentOutputPools: [FfiPaymentOutputPool] = [] + /// The anchor height to be used in creating the transaction, if any. /// Setting the anchor height to zero will disallow the use of any shielded /// inputs. @@ -174,16 +202,7 @@ struct FfiProposal { /// Clears the value of `balance`. Subsequent reads from it will return its default value. mutating func clearBalance() {self._balance = nil} - /// The fee rule used in constructing this proposal - var feeRule: FfiFeeRule = .notSpecified - - /// The target height for which the proposal was constructed - /// - /// The chain must contain at least this many blocks in order for the proposal to - /// be executed. - var minTargetHeight: UInt32 = 0 - - /// A flag indicating whether the proposal is for a shielding transaction, + /// A flag indicating whether the step is for a shielding transaction, /// used for determining which OVK to select for wallet-internal outputs. var isShielding: Bool = false @@ -194,8 +213,26 @@ struct FfiProposal { fileprivate var _balance: FfiTransactionBalance? = nil } -/// The unique identifier and value for each proposed input. -struct FfiProposedInput { +/// A mapping from ZIP 321 payment index to the output pool that has been chosen +/// for that payment, based upon the payment address and the selected inputs to +/// the transaction. +struct FfiPaymentOutputPool { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var paymentIndex: UInt32 = 0 + + var valuePool: FfiValuePool = .poolNotSpecified + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +/// The unique identifier and value for each proposed input that does not +/// require a back-reference to a prior step of the proposal. +struct FfiReceivedOutput { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -213,14 +250,113 @@ struct FfiProposedInput { init() {} } +/// A reference a payment in a prior step of the proposal. This payment must +/// belong to the wallet. +struct FfiPriorStepOutput { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var stepIndex: UInt32 = 0 + + var paymentIndex: UInt32 = 0 + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +/// A reference a change output from a prior step of the proposal. +struct FfiPriorStepChange { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var stepIndex: UInt32 = 0 + + var changeIndex: UInt32 = 0 + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +/// The unique identifier and value for an input to be used in the transaction. +struct FfiProposedInput { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var value: FfiProposedInput.OneOf_Value? = nil + + var receivedOutput: FfiReceivedOutput { + get { + if case .receivedOutput(let v)? = value {return v} + return FfiReceivedOutput() + } + set {value = .receivedOutput(newValue)} + } + + var priorStepOutput: FfiPriorStepOutput { + get { + if case .priorStepOutput(let v)? = value {return v} + return FfiPriorStepOutput() + } + set {value = .priorStepOutput(newValue)} + } + + var priorStepChange: FfiPriorStepChange { + get { + if case .priorStepChange(let v)? = value {return v} + return FfiPriorStepChange() + } + set {value = .priorStepChange(newValue)} + } + + var unknownFields = SwiftProtobuf.UnknownStorage() + + enum OneOf_Value: Equatable { + case receivedOutput(FfiReceivedOutput) + case priorStepOutput(FfiPriorStepOutput) + case priorStepChange(FfiPriorStepChange) + + #if !swift(>=4.1) + static func ==(lhs: FfiProposedInput.OneOf_Value, rhs: FfiProposedInput.OneOf_Value) -> Bool { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch (lhs, rhs) { + case (.receivedOutput, .receivedOutput): return { + guard case .receivedOutput(let l) = lhs, case .receivedOutput(let r) = rhs else { preconditionFailure() } + return l == r + }() + case (.priorStepOutput, .priorStepOutput): return { + guard case .priorStepOutput(let l) = lhs, case .priorStepOutput(let r) = rhs else { preconditionFailure() } + return l == r + }() + case (.priorStepChange, .priorStepChange): return { + guard case .priorStepChange(let l) = lhs, case .priorStepChange(let r) = rhs else { preconditionFailure() } + return l == r + }() + default: return false + } + } + #endif + } + + init() {} +} + /// The proposed change outputs and fee value. struct FfiTransactionBalance { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. + /// A list of change output values. var proposedChange: [FfiChangeValue] = [] + /// The fee to be paid by the proposed transaction, in zatoshis. var feeRequired: UInt64 = 0 var unknownFields = SwiftProtobuf.UnknownStorage() @@ -235,10 +371,14 @@ struct FfiChangeValue { // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. + /// The value of a change output to be created, in zatoshis. var value: UInt64 = 0 + /// The value pool in which the change output should be created. var valuePool: FfiValuePool = .poolNotSpecified + /// The optional memo that should be associated with the newly created change output. + /// Memos must not be present for transparent change outputs. var memo: FfiMemoBytes { get {return _memo ?? FfiMemoBytes()} set {_memo = newValue} @@ -273,7 +413,13 @@ struct FfiMemoBytes { extension FfiValuePool: @unchecked Sendable {} extension FfiFeeRule: @unchecked Sendable {} extension FfiProposal: @unchecked Sendable {} +extension FfiProposalStep: @unchecked Sendable {} +extension FfiPaymentOutputPool: @unchecked Sendable {} +extension FfiReceivedOutput: @unchecked Sendable {} +extension FfiPriorStepOutput: @unchecked Sendable {} +extension FfiPriorStepChange: @unchecked Sendable {} extension FfiProposedInput: @unchecked Sendable {} +extension FfiProposedInput.OneOf_Value: @unchecked Sendable {} extension FfiTransactionBalance: @unchecked Sendable {} extension FfiChangeValue: @unchecked Sendable {} extension FfiMemoBytes: @unchecked Sendable {} @@ -305,13 +451,9 @@ extension FfiProposal: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementati static let protoMessageName: String = _protobuf_package + ".Proposal" static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "protoVersion"), - 2: .same(proto: "transactionRequest"), - 3: .same(proto: "anchorHeight"), - 4: .same(proto: "inputs"), - 5: .same(proto: "balance"), - 6: .same(proto: "feeRule"), - 7: .same(proto: "minTargetHeight"), - 8: .same(proto: "isShielding"), + 2: .same(proto: "feeRule"), + 3: .same(proto: "minTargetHeight"), + 4: .same(proto: "steps"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -321,13 +463,63 @@ extension FfiProposal: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementati // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularUInt32Field(value: &self.protoVersion) }() - case 2: try { try decoder.decodeSingularStringField(value: &self.transactionRequest) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self.feeRule) }() + case 3: try { try decoder.decodeSingularUInt32Field(value: &self.minTargetHeight) }() + case 4: try { try decoder.decodeRepeatedMessageField(value: &self.steps) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.protoVersion != 0 { + try visitor.visitSingularUInt32Field(value: self.protoVersion, fieldNumber: 1) + } + if self.feeRule != .notSpecified { + try visitor.visitSingularEnumField(value: self.feeRule, fieldNumber: 2) + } + if self.minTargetHeight != 0 { + try visitor.visitSingularUInt32Field(value: self.minTargetHeight, fieldNumber: 3) + } + if !self.steps.isEmpty { + try visitor.visitRepeatedMessageField(value: self.steps, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: FfiProposal, rhs: FfiProposal) -> Bool { + if lhs.protoVersion != rhs.protoVersion {return false} + if lhs.feeRule != rhs.feeRule {return false} + if lhs.minTargetHeight != rhs.minTargetHeight {return false} + if lhs.steps != rhs.steps {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension FfiProposalStep: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".ProposalStep" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "transactionRequest"), + 2: .same(proto: "paymentOutputPools"), + 3: .same(proto: "anchorHeight"), + 4: .same(proto: "inputs"), + 5: .same(proto: "balance"), + 6: .same(proto: "isShielding"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.transactionRequest) }() + case 2: try { try decoder.decodeRepeatedMessageField(value: &self.paymentOutputPools) }() case 3: try { try decoder.decodeSingularUInt32Field(value: &self.anchorHeight) }() case 4: try { try decoder.decodeRepeatedMessageField(value: &self.inputs) }() case 5: try { try decoder.decodeSingularMessageField(value: &self._balance) }() - case 6: try { try decoder.decodeSingularEnumField(value: &self.feeRule) }() - case 7: try { try decoder.decodeSingularUInt32Field(value: &self.minTargetHeight) }() - case 8: try { try decoder.decodeSingularBoolField(value: &self.isShielding) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self.isShielding) }() default: break } } @@ -338,11 +530,11 @@ extension FfiProposal: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementati // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 - if self.protoVersion != 0 { - try visitor.visitSingularUInt32Field(value: self.protoVersion, fieldNumber: 1) - } if !self.transactionRequest.isEmpty { - try visitor.visitSingularStringField(value: self.transactionRequest, fieldNumber: 2) + try visitor.visitSingularStringField(value: self.transactionRequest, fieldNumber: 1) + } + if !self.paymentOutputPools.isEmpty { + try visitor.visitRepeatedMessageField(value: self.paymentOutputPools, fieldNumber: 2) } if self.anchorHeight != 0 { try visitor.visitSingularUInt32Field(value: self.anchorHeight, fieldNumber: 3) @@ -353,34 +545,64 @@ extension FfiProposal: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementati try { if let v = self._balance { try visitor.visitSingularMessageField(value: v, fieldNumber: 5) } }() - if self.feeRule != .notSpecified { - try visitor.visitSingularEnumField(value: self.feeRule, fieldNumber: 6) - } - if self.minTargetHeight != 0 { - try visitor.visitSingularUInt32Field(value: self.minTargetHeight, fieldNumber: 7) - } if self.isShielding != false { - try visitor.visitSingularBoolField(value: self.isShielding, fieldNumber: 8) + try visitor.visitSingularBoolField(value: self.isShielding, fieldNumber: 6) } try unknownFields.traverse(visitor: &visitor) } - static func ==(lhs: FfiProposal, rhs: FfiProposal) -> Bool { - if lhs.protoVersion != rhs.protoVersion {return false} + static func ==(lhs: FfiProposalStep, rhs: FfiProposalStep) -> Bool { if lhs.transactionRequest != rhs.transactionRequest {return false} + if lhs.paymentOutputPools != rhs.paymentOutputPools {return false} if lhs.anchorHeight != rhs.anchorHeight {return false} if lhs.inputs != rhs.inputs {return false} if lhs._balance != rhs._balance {return false} - if lhs.feeRule != rhs.feeRule {return false} - if lhs.minTargetHeight != rhs.minTargetHeight {return false} if lhs.isShielding != rhs.isShielding {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } -extension FfiProposedInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = _protobuf_package + ".ProposedInput" +extension FfiPaymentOutputPool: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".PaymentOutputPool" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "paymentIndex"), + 2: .same(proto: "valuePool"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self.paymentIndex) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self.valuePool) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.paymentIndex != 0 { + try visitor.visitSingularUInt32Field(value: self.paymentIndex, fieldNumber: 1) + } + if self.valuePool != .poolNotSpecified { + try visitor.visitSingularEnumField(value: self.valuePool, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: FfiPaymentOutputPool, rhs: FfiPaymentOutputPool) -> Bool { + if lhs.paymentIndex != rhs.paymentIndex {return false} + if lhs.valuePool != rhs.valuePool {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension FfiReceivedOutput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".ReceivedOutput" static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "txid"), 2: .same(proto: "valuePool"), @@ -419,7 +641,7 @@ extension FfiProposedInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme try unknownFields.traverse(visitor: &visitor) } - static func ==(lhs: FfiProposedInput, rhs: FfiProposedInput) -> Bool { + static func ==(lhs: FfiReceivedOutput, rhs: FfiReceivedOutput) -> Bool { if lhs.txid != rhs.txid {return false} if lhs.valuePool != rhs.valuePool {return false} if lhs.index != rhs.index {return false} @@ -429,6 +651,170 @@ extension FfiProposedInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme } } +extension FfiPriorStepOutput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".PriorStepOutput" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "stepIndex"), + 2: .same(proto: "paymentIndex"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self.stepIndex) }() + case 2: try { try decoder.decodeSingularUInt32Field(value: &self.paymentIndex) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.stepIndex != 0 { + try visitor.visitSingularUInt32Field(value: self.stepIndex, fieldNumber: 1) + } + if self.paymentIndex != 0 { + try visitor.visitSingularUInt32Field(value: self.paymentIndex, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: FfiPriorStepOutput, rhs: FfiPriorStepOutput) -> Bool { + if lhs.stepIndex != rhs.stepIndex {return false} + if lhs.paymentIndex != rhs.paymentIndex {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension FfiPriorStepChange: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".PriorStepChange" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "stepIndex"), + 2: .same(proto: "changeIndex"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self.stepIndex) }() + case 2: try { try decoder.decodeSingularUInt32Field(value: &self.changeIndex) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.stepIndex != 0 { + try visitor.visitSingularUInt32Field(value: self.stepIndex, fieldNumber: 1) + } + if self.changeIndex != 0 { + try visitor.visitSingularUInt32Field(value: self.changeIndex, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: FfiPriorStepChange, rhs: FfiPriorStepChange) -> Bool { + if lhs.stepIndex != rhs.stepIndex {return false} + if lhs.changeIndex != rhs.changeIndex {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension FfiProposedInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".ProposedInput" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "receivedOutput"), + 2: .same(proto: "priorStepOutput"), + 3: .same(proto: "priorStepChange"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { + var v: FfiReceivedOutput? + var hadOneofValue = false + if let current = self.value { + hadOneofValue = true + if case .receivedOutput(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.value = .receivedOutput(v) + } + }() + case 2: try { + var v: FfiPriorStepOutput? + var hadOneofValue = false + if let current = self.value { + hadOneofValue = true + if case .priorStepOutput(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.value = .priorStepOutput(v) + } + }() + case 3: try { + var v: FfiPriorStepChange? + var hadOneofValue = false + if let current = self.value { + hadOneofValue = true + if case .priorStepChange(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.value = .priorStepChange(v) + } + }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + switch self.value { + case .receivedOutput?: try { + guard case .receivedOutput(let v)? = self.value else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + }() + case .priorStepOutput?: try { + guard case .priorStepOutput(let v)? = self.value else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case .priorStepChange?: try { + guard case .priorStepChange(let v)? = self.value else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: FfiProposedInput, rhs: FfiProposedInput) -> Bool { + if lhs.value != rhs.value {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension FfiTransactionBalance: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".TransactionBalance" static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ diff --git a/Sources/ZcashLightClientKit/Modules/Service/GRPC/ProtoBuf/proto/proposal.proto b/Sources/ZcashLightClientKit/Modules/Service/GRPC/ProtoBuf/proto/proposal.proto index 3ebee76f..1af5ec27 100644 --- a/Sources/ZcashLightClientKit/Modules/Service/GRPC/ProtoBuf/proto/proposal.proto +++ b/Sources/ZcashLightClientKit/Modules/Service/GRPC/ProtoBuf/proto/proposal.proto @@ -6,12 +6,29 @@ syntax = "proto3"; package cash.z.wallet.sdk.ffi; option swift_prefix = "Ffi"; +// A data structure that describes a series of transactions to be created. +message Proposal { + // The version of this serialization format. + uint32 protoVersion = 1; + // The fee rule used in constructing this proposal + FeeRule feeRule = 2; + // The target height for which the proposal was constructed + // + // The chain must contain at least this many blocks in order for the proposal to + // be executed. + uint32 minTargetHeight = 3; + // The series of transactions to be created. + repeated ProposalStep steps = 4; +} + // A data structure that describes the inputs to be consumed and outputs to // be produced in a proposed transaction. -message Proposal { - uint32 protoVersion = 1; +message ProposalStep { // ZIP 321 serialized transaction request - string transactionRequest = 2; + string transactionRequest = 1; + // The vector of selected payment index / output pool mappings. Payment index + // 0 corresponds to the payment with no explicit index. + repeated PaymentOutputPool paymentOutputPools = 2; // The anchor height to be used in creating the transaction, if any. // Setting the anchor height to zero will disallow the use of any shielded // inputs. @@ -21,16 +38,9 @@ message Proposal { // The total value, fee value, and change outputs of the proposed // transaction TransactionBalance balance = 5; - // The fee rule used in constructing this proposal - FeeRule feeRule = 6; - // The target height for which the proposal was constructed - // - // The chain must contain at least this many blocks in order for the proposal to - // be executed. - uint32 minTargetHeight = 7; - // A flag indicating whether the proposal is for a shielding transaction, + // A flag indicating whether the step is for a shielding transaction, // used for determining which OVK to select for wallet-internal outputs. - bool isShielding = 8; + bool isShielding = 6; } enum ValuePool { @@ -47,14 +57,45 @@ enum ValuePool { Orchard = 3; } -// The unique identifier and value for each proposed input. -message ProposedInput { +// A mapping from ZIP 321 payment index to the output pool that has been chosen +// for that payment, based upon the payment address and the selected inputs to +// the transaction. +message PaymentOutputPool { + uint32 paymentIndex = 1; + ValuePool valuePool = 2; +} + +// The unique identifier and value for each proposed input that does not +// require a back-reference to a prior step of the proposal. +message ReceivedOutput { bytes txid = 1; ValuePool valuePool = 2; uint32 index = 3; uint64 value = 4; } +// A reference a payment in a prior step of the proposal. This payment must +// belong to the wallet. +message PriorStepOutput { + uint32 stepIndex = 1; + uint32 paymentIndex = 2; +} + +// A reference a change output from a prior step of the proposal. +message PriorStepChange { + uint32 stepIndex = 1; + uint32 changeIndex = 2; +} + +// The unique identifier and value for an input to be used in the transaction. +message ProposedInput { + oneof value { + ReceivedOutput receivedOutput = 1; + PriorStepOutput priorStepOutput = 2; + PriorStepChange priorStepChange = 3; + } +} + // The fee rule used in constructing a Proposal enum FeeRule { // Protobuf requires that enums have a zero discriminant as the default @@ -72,15 +113,21 @@ enum FeeRule { // The proposed change outputs and fee value. message TransactionBalance { + // A list of change output values. repeated ChangeValue proposedChange = 1; + // The fee to be paid by the proposed transaction, in zatoshis. uint64 feeRequired = 2; } // A proposed change output. If the transparent value pool is selected, // the `memo` field must be null. message ChangeValue { + // The value of a change output to be created, in zatoshis. uint64 value = 1; + // The value pool in which the change output should be created. ValuePool valuePool = 2; + // The optional memo that should be associated with the newly created change output. + // Memos must not be present for transparent change outputs. MemoBytes memo = 3; } diff --git a/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift b/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift index 290bce63..fa3b4a05 100644 --- a/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift +++ b/Sources/ZcashLightClientKit/Rust/ZcashRustBackend.swift @@ -622,10 +622,6 @@ actor ZcashRustBackend: ZcashRustBackendWelding { shieldingThreshold: Zatoshi, transparentReceiver: String? ) async throws -> FfiProposal? { - if transparentReceiver != nil { - throw ZcashError.rustScanBlocks("TODO: Implement transparentReceiver support in FFI") - } - globalDBLock.lock() let proposal = zcashlc_propose_shielding( dbData.0, @@ -633,6 +629,7 @@ actor ZcashRustBackend: ZcashRustBackendWelding { account, memo?.bytes, UInt64(shieldingThreshold.amount), + transparentReceiver.map { [CChar]($0.utf8CString) }, networkType.networkId, minimumConfirmations, useZIP317Fees @@ -651,44 +648,46 @@ actor ZcashRustBackend: ZcashRustBackendWelding { )) } - func createProposedTransaction( + func createProposedTransactions( proposal: FfiProposal, usk: UnifiedSpendingKey - ) async throws -> Data { - var contiguousTxIdBytes = ContiguousArray([UInt8](repeating: 0x0, count: 32)) - + ) async throws -> [Data] { let proposalBytes = try proposal.serializedData(partial: false).bytes globalDBLock.lock() - let success = contiguousTxIdBytes.withUnsafeMutableBufferPointer { txIdBytePtr in - proposalBytes.withUnsafeBufferPointer { proposalPtr in - usk.bytes.withUnsafeBufferPointer { uskPtr in - zcashlc_create_proposed_transaction( - dbData.0, - dbData.1, - proposalPtr.baseAddress, - UInt(proposalBytes.count), - uskPtr.baseAddress, - UInt(usk.bytes.count), - spendParamsPath.0, - spendParamsPath.1, - outputParamsPath.0, - outputParamsPath.1, - networkType.networkId, - txIdBytePtr.baseAddress - ) - } + let txIdsPtr = proposalBytes.withUnsafeBufferPointer { proposalPtr in + usk.bytes.withUnsafeBufferPointer { uskPtr in + zcashlc_create_proposed_transactions( + dbData.0, + dbData.1, + proposalPtr.baseAddress, + UInt(proposalBytes.count), + uskPtr.baseAddress, + UInt(usk.bytes.count), + spendParamsPath.0, + spendParamsPath.1, + outputParamsPath.0, + outputParamsPath.1, + networkType.networkId + ) } } globalDBLock.unlock() - guard success else { + guard let txIdsPtr else { throw ZcashError.rustCreateToAddress(lastErrorMessage(fallback: "`createToAddress` failed with unknown error")) } - return contiguousTxIdBytes.withUnsafeBufferPointer { txIdBytePtr in - Data(txIdBytePtr) + defer { zcashlc_free_txids(txIdsPtr) } + + var txIds: [Data] = [] + + for i in (0 ..< Int(txIdsPtr.pointee.len)) { + let txId = FfiTxId(tuple: txIdsPtr.pointee.ptr.advanced(by: i).pointee) + txIds.append(Data(txId.array)) } + + return txIds } nonisolated func consensusBranchIdFor(height: Int32) throws -> Int32 { @@ -828,3 +827,12 @@ extension FfiScanProgress { ) } } + +struct FfiTxId { + var tuple: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) + var array: [UInt8] { + withUnsafeBytes(of: self.tuple) { buf in + [UInt8](buf) + } + } +} diff --git a/Sources/ZcashLightClientKit/Rust/ZcashRustBackendWelding.swift b/Sources/ZcashLightClientKit/Rust/ZcashRustBackendWelding.swift index fd6cc03f..8843dea1 100644 --- a/Sources/ZcashLightClientKit/Rust/ZcashRustBackendWelding.swift +++ b/Sources/ZcashLightClientKit/Rust/ZcashRustBackendWelding.swift @@ -233,10 +233,10 @@ protocol ZcashRustBackendWelding { /// - Parameter proposal: the transaction proposal. /// - Parameter usk: `UnifiedSpendingKey` for the account that controls the funds to be spent. /// - Throws: `rustCreateToAddress`. - func createProposedTransaction( + func createProposedTransactions( proposal: FfiProposal, usk: UnifiedSpendingKey - ) async throws -> Data + ) async throws -> [Data] /// Gets the consensus branch id for the given height /// - Parameter height: the height you what to know the branch id for diff --git a/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift b/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift index 5e9ed9bd..31e4f796 100644 --- a/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift +++ b/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift @@ -95,13 +95,20 @@ class WalletTransactionEncoder: TransactionEncoder { throw ZcashError.walletTransEncoderCreateTransactionMissingSaplingParams } - let txId = try await rustBackend.createProposedTransaction( + let txIds = try await rustBackend.createProposedTransactions( proposal: proposal.inner, usk: spendingKey ) - logger.debug("transaction id: \(txId)") - return [try await repository.find(rawID: txId)] + logger.debug("transaction ids: \(txIds)") + + var txs: [ZcashTransaction.Overview] = [] + + for txId in txIds { + txs.append(try await repository.find(rawID: txId)) + } + + return txs } func submit( diff --git a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift index ebb863bc..156d078a 100644 --- a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift +++ b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift @@ -2859,36 +2859,36 @@ actor ZcashRustBackendWeldingMock: ZcashRustBackendWelding { } } - // MARK: - createProposedTransaction + // MARK: - createProposedTransactions - var createProposedTransactionProposalUskThrowableError: Error? - func setCreateProposedTransactionProposalUskThrowableError(_ param: Error?) async { - createProposedTransactionProposalUskThrowableError = param + var createProposedTransactionsProposalUskThrowableError: Error? + func setCreateProposedTransactionsProposalUskThrowableError(_ param: Error?) async { + createProposedTransactionsProposalUskThrowableError = param } - var createProposedTransactionProposalUskCallsCount = 0 - var createProposedTransactionProposalUskCalled: Bool { - return createProposedTransactionProposalUskCallsCount > 0 + var createProposedTransactionsProposalUskCallsCount = 0 + var createProposedTransactionsProposalUskCalled: Bool { + return createProposedTransactionsProposalUskCallsCount > 0 } - var createProposedTransactionProposalUskReceivedArguments: (proposal: FfiProposal, usk: UnifiedSpendingKey)? - var createProposedTransactionProposalUskReturnValue: Data! - func setCreateProposedTransactionProposalUskReturnValue(_ param: Data) async { - createProposedTransactionProposalUskReturnValue = param + var createProposedTransactionsProposalUskReceivedArguments: (proposal: FfiProposal, usk: UnifiedSpendingKey)? + var createProposedTransactionsProposalUskReturnValue: [Data]! + func setCreateProposedTransactionsProposalUskReturnValue(_ param: [Data]) async { + createProposedTransactionsProposalUskReturnValue = param } - var createProposedTransactionProposalUskClosure: ((FfiProposal, UnifiedSpendingKey) async throws -> Data)? - func setCreateProposedTransactionProposalUskClosure(_ param: ((FfiProposal, UnifiedSpendingKey) async throws -> Data)?) async { - createProposedTransactionProposalUskClosure = param + var createProposedTransactionsProposalUskClosure: ((FfiProposal, UnifiedSpendingKey) async throws -> [Data])? + func setCreateProposedTransactionsProposalUskClosure(_ param: ((FfiProposal, UnifiedSpendingKey) async throws -> [Data])?) async { + createProposedTransactionsProposalUskClosure = param } - func createProposedTransaction(proposal: FfiProposal, usk: UnifiedSpendingKey) async throws -> Data { - if let error = createProposedTransactionProposalUskThrowableError { + func createProposedTransactions(proposal: FfiProposal, usk: UnifiedSpendingKey) async throws -> [Data] { + if let error = createProposedTransactionsProposalUskThrowableError { throw error } - createProposedTransactionProposalUskCallsCount += 1 - createProposedTransactionProposalUskReceivedArguments = (proposal: proposal, usk: usk) - if let closure = createProposedTransactionProposalUskClosure { + createProposedTransactionsProposalUskCallsCount += 1 + createProposedTransactionsProposalUskReceivedArguments = (proposal: proposal, usk: usk) + if let closure = createProposedTransactionsProposalUskClosure { return try await closure(proposal, usk) } else { - return createProposedTransactionProposalUskReturnValue + return createProposedTransactionsProposalUskReturnValue } } diff --git a/Tests/TestUtils/Stubs.swift b/Tests/TestUtils/Stubs.swift index dbc62987..f5f33521 100644 --- a/Tests/TestUtils/Stubs.swift +++ b/Tests/TestUtils/Stubs.swift @@ -85,7 +85,7 @@ class RustBackendMockHelper { await rustBackendMock.setPutUnspentTransparentOutputTxidIndexScriptValueHeightClosure() { _, _, _, _, _ in } await rustBackendMock.setProposeTransferAccountToValueMemoThrowableError(ZcashError.rustCreateToAddress("mocked error")) await rustBackendMock.setProposeShieldingAccountMemoShieldingThresholdTransparentReceiverThrowableError(ZcashError.rustShieldFunds("mocked error")) - await rustBackendMock.setCreateProposedTransactionProposalUskThrowableError(ZcashError.rustCreateToAddress("mocked error")) + await rustBackendMock.setCreateProposedTransactionsProposalUskThrowableError(ZcashError.rustCreateToAddress("mocked error")) await rustBackendMock.setDecryptAndStoreTransactionTxBytesMinedHeightThrowableError(ZcashError.rustDecryptAndStoreTransaction("mock fail")) await rustBackendMock.setInitDataDbSeedClosure() { seed in From 9b2be559cd93d717779f0f6e189578273ce5e01b Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Thu, 7 Mar 2024 18:50:31 +0000 Subject: [PATCH 08/10] Add `Proposal.transactionCount` --- Sources/ZcashLightClientKit/Model/Proposal.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/ZcashLightClientKit/Model/Proposal.swift b/Sources/ZcashLightClientKit/Model/Proposal.swift index 6a238f66..c462b79c 100644 --- a/Sources/ZcashLightClientKit/Model/Proposal.swift +++ b/Sources/ZcashLightClientKit/Model/Proposal.swift @@ -11,6 +11,16 @@ import Foundation public struct Proposal: Equatable { let inner: FfiProposal + /// Returns the number of transactions that this proposal will create. + /// + /// This is equal to the number of `TransactionSubmitResult`s that will be returned + /// from `Synchronizer.createProposedTransactions`. + /// + /// Proposals always create at least one transaction. + public func transactionCount() -> Int { + inner.steps.count + } + /// Returns the total fee to be paid across all proposed transactions, in zatoshis. public func totalFeeRequired() -> Zatoshi { inner.steps.reduce(Zatoshi.zero) { acc, step in From 23fd0698d4faae391ed45f1b46907400cd1b066c Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Fri, 8 Mar 2024 00:24:29 +0000 Subject: [PATCH 09/10] Migrate to FFI 0.6.0 release --- CHANGELOG.md | 1 + .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 3 ++- Package.resolved | 3 ++- Package.swift | 3 +-- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6251f2..3f34dce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased ## Changed +- Migrated to `zcash-light-client-ffi 0.6.0`. ### [#1186] Enable ZIP 317 fees - The SDK now generates transactions using [ZIP 317](https://zips.z.cash/zip-0317) fees, diff --git a/Example/ZcashLightClientSample/ZcashLightClientSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/ZcashLightClientSample/ZcashLightClientSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 145b2a4b..15d91fe3 100644 --- a/Example/ZcashLightClientSample/ZcashLightClientSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/ZcashLightClientSample/ZcashLightClientSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -176,7 +176,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/zcash-hackworks/zcash-light-client-ffi", "state" : { - "revision" : "789d0c068fb32e2ab149cdd785f16e0ac88f3594" + "revision" : "7c801be1f445402a433b32835a50d832e8a50437", + "version" : "0.6.0" } } ], diff --git a/Package.resolved b/Package.resolved index 7cefa11b..6ed2aaaf 100644 --- a/Package.resolved +++ b/Package.resolved @@ -122,7 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/zcash-hackworks/zcash-light-client-ffi", "state" : { - "revision" : "789d0c068fb32e2ab149cdd785f16e0ac88f3594" + "revision" : "7c801be1f445402a433b32835a50d832e8a50437", + "version" : "0.6.0" } } ], diff --git a/Package.swift b/Package.swift index dd4795c2..f86b18e9 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.19.1"), .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.14.1"), - // Compiled from revision `97e09ed3709ae9f26226587bec852a725bc783a4`. - .package(url: "https://github.com/zcash-hackworks/zcash-light-client-ffi", revision: "789d0c068fb32e2ab149cdd785f16e0ac88f3594") + .package(url: "https://github.com/zcash-hackworks/zcash-light-client-ffi", exact: "0.6.0") ], targets: [ .target( From 129ac4398e86a62abd73056d55c26ba947dd5ef6 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Fri, 8 Mar 2024 00:41:58 +0000 Subject: [PATCH 10/10] Add missing changes to protocols for `Synchronizer` wrappers --- .../ClosureSynchronizer.swift | 59 +++++++++++++++++++ .../CombineSynchronizer.swift | 56 ++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/Sources/ZcashLightClientKit/ClosureSynchronizer.swift b/Sources/ZcashLightClientKit/ClosureSynchronizer.swift index 66ad1d22..18d9b1ea 100644 --- a/Sources/ZcashLightClientKit/ClosureSynchronizer.swift +++ b/Sources/ZcashLightClientKit/ClosureSynchronizer.swift @@ -36,6 +36,64 @@ public protocol ClosureSynchronizer { func getUnifiedAddress(accountIndex: Int, completion: @escaping (Result) -> Void) func getTransparentAddress(accountIndex: Int, completion: @escaping (Result) -> Void) + /// Creates a proposal for transferring funds to the given recipient. + /// + /// - Parameter accountIndex: the account from which to transfer funds. + /// - Parameter recipient: the recipient's address. + /// - Parameter amount: the amount to send in Zatoshi. + /// - Parameter memo: an optional memo to include as part of the proposal's transactions. Use `nil` when sending to transparent receivers otherwise the function will throw an error. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws + /// `SynchronizerErrors.notPrepared`. + func proposeTransfer( + accountIndex: Int, + recipient: Recipient, + amount: Zatoshi, + memo: Memo?, + completion: @escaping (Result) -> Void + ) + + /// Creates a proposal for shielding any transparent funds received by the given account. + /// + /// - Parameter accountIndex: the account for which to shield funds. + /// - Parameter shieldingThreshold: the minimum transparent balance required before a proposal will be created. + /// - Parameter memo: an optional memo to include as part of the proposal's transactions. + /// - Parameter transparentReceiver: a specific transparent receiver within the account + /// that should be the source of transparent funds. Default is `nil` which + /// will select whichever of the account's transparent receivers has funds + /// to shield. + /// + /// Returns the proposal, or `nil` if the transparent balance that would be shielded + /// is zero or below `shieldingThreshold`. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws + /// `SynchronizerErrors.notPrepared`. + func proposeShielding( + accountIndex: Int, + shieldingThreshold: Zatoshi, + memo: Memo, + transparentReceiver: TransparentAddress?, + completion: @escaping (Result) -> Void + ) + + /// Creates the transactions in the given proposal. + /// + /// - Parameter proposal: the proposal for which to create transactions. + /// - Parameter spendingKey: the `UnifiedSpendingKey` associated with the account for which the proposal was created. + /// + /// Returns a stream of objects for the transactions that were created as part of the + /// proposal, indicating whether they were submitted to the network or if an error + /// occurred. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance + /// or since the last wipe then this method throws `SynchronizerErrors.notPrepared`. + func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey, + completion: @escaping (Result, Error>) -> Void + ) + + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, @@ -44,6 +102,7 @@ public protocol ClosureSynchronizer { completion: @escaping (Result) -> Void ) + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") func shieldFunds( spendingKey: UnifiedSpendingKey, memo: Memo, diff --git a/Sources/ZcashLightClientKit/CombineSynchronizer.swift b/Sources/ZcashLightClientKit/CombineSynchronizer.swift index 2581af68..324e5336 100644 --- a/Sources/ZcashLightClientKit/CombineSynchronizer.swift +++ b/Sources/ZcashLightClientKit/CombineSynchronizer.swift @@ -35,6 +35,61 @@ public protocol CombineSynchronizer { func getUnifiedAddress(accountIndex: Int) -> SinglePublisher func getTransparentAddress(accountIndex: Int) -> SinglePublisher + /// Creates a proposal for transferring funds to the given recipient. + /// + /// - Parameter accountIndex: the account from which to transfer funds. + /// - Parameter recipient: the recipient's address. + /// - Parameter amount: the amount to send in Zatoshi. + /// - Parameter memo: an optional memo to include as part of the proposal's transactions. Use `nil` when sending to transparent receivers otherwise the function will throw an error. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws + /// `SynchronizerErrors.notPrepared`. + func proposeTransfer( + accountIndex: Int, + recipient: Recipient, + amount: Zatoshi, + memo: Memo? + ) -> SinglePublisher + + /// Creates a proposal for shielding any transparent funds received by the given account. + /// + /// - Parameter accountIndex: the account for which to shield funds. + /// - Parameter shieldingThreshold: the minimum transparent balance required before a proposal will be created. + /// - Parameter memo: an optional memo to include as part of the proposal's transactions. + /// - Parameter transparentReceiver: a specific transparent receiver within the account + /// that should be the source of transparent funds. Default is `nil` which + /// will select whichever of the account's transparent receivers has funds + /// to shield. + /// + /// Returns the proposal, or `nil` if the transparent balance that would be shielded + /// is zero or below `shieldingThreshold`. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws + /// `SynchronizerErrors.notPrepared`. + func proposeShielding( + accountIndex: Int, + shieldingThreshold: Zatoshi, + memo: Memo, + transparentReceiver: TransparentAddress? + ) -> SinglePublisher + + /// Creates the transactions in the given proposal. + /// + /// - Parameter proposal: the proposal for which to create transactions. + /// - Parameter spendingKey: the `UnifiedSpendingKey` associated with the account for which the proposal was created. + /// + /// Returns a stream of objects for the transactions that were created as part of the + /// proposal, indicating whether they were submitted to the network or if an error + /// occurred. + /// + /// If `prepare()` hasn't already been called since creation of the synchronizer instance + /// or since the last wipe then this method throws `SynchronizerErrors.notPrepared`. + func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) -> SinglePublisher, Error> + + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, @@ -42,6 +97,7 @@ public protocol CombineSynchronizer { memo: Memo? ) -> SinglePublisher + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") func shieldFunds( spendingKey: UnifiedSpendingKey, memo: Memo,