diff --git a/CHANGELOG.md b/CHANGELOG.md index 204844297..3e87280c1 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 000000000..0111c5882 --- /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 { + let inner: FfiProposal + + /// Returns the total fee to be paid across all proposed transactions, in zatoshis. + public func totalFeeRequired() -> Zatoshi { + return Zatoshi(Int64(inner.balance.feeRequired)) + } +} diff --git a/Sources/ZcashLightClientKit/Synchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer.swift index f675d0dab..0d55c3b75 100644 --- a/Sources/ZcashLightClientKit/Synchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer.swift @@ -154,7 +154,48 @@ 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 creating of 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 memo: an optional memo to include as part of the proposal's transactions. + /// + /// If `prepare()` hasn't already been called since creating of 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 to create. + /// - Parameter spendingKey: the `UnifiedSpendingKey` associated with the notes that will be spent. + /// + /// If `prepare()` hasn't already been called since creating of synchronizer instance or since the last wipe then this method throws + /// `SynchronizerErrors.notPrepared`. + func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) async throws -> [ZcashTransaction.Overview] + /// Sends zatoshi. /// - Parameter spendingKey: the `UnifiedSpendingKey` that allows spends to occur. /// - Parameter zatoshi: the amount to send in Zatoshi. diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift index a6d84f776..03494c5a7 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift @@ -265,6 +265,60 @@ 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 -> [ZcashTransaction.Overview] { + 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 + ) + + for transaction in transactions { + let encodedTransaction = try transaction.encodedTransaction() + + try await transactionEncoder.submit(transaction: encodedTransaction) + } + + return transactions + } + public func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, @@ -312,13 +366,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 +400,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 3aef616ce..c6c7ca756 100644 --- a/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift +++ b/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift @@ -19,44 +19,49 @@ 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 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 creating of 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 memo: an optional memo to include as part of the proposal's transactions. + /// + /// If `prepare()` hasn't already been called since creating of 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 to create. + /// - Parameter spendingKey: the `UnifiedSpendingKey` associated with the notes that will be spent. /// - 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). /// - /// - 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 creating of 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 f24927bd7..fb4c2c1bd 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 - ) - - logger.debug("transaction id: \(txId)") - return try await repository.find(rawID: txId) - } - - func createSpend( - spendingKey: UnifiedSpendingKey, - zatoshi: Zatoshi, - to address: String, - memoBytes: MemoBytes?, - from accountIndex: Int - ) async throws -> Data { - guard ensureParams(spend: self.spendParamsURL, output: self.outputParamsURL) else { - throw ZcashError.walletTransEncoderCreateTransactionMissingSaplingParams - } - // TODO: Expose the proposal in a way that enables querying its fee. + func proposeTransfer( + accountIndex: Int, + recipient: String, + amount: Zatoshi, + memoBytes: MemoBytes? + ) async throws -> Proposal { let proposal = try await rustBackend.proposeTransfer( - account: Int32(spendingKey.account), - to: address, - value: zatoshi.amount, + account: Int32(accountIndex), + to: recipient, + value: amount.amount, memo: memoBytes ) - let txId = try await rustBackend.createProposedTransaction( - proposal: proposal, - usk: spendingKey - ) - - return txId + return Proposal(inner: proposal) } - - func createShieldingTransaction( - spendingKey: UnifiedSpendingKey, + + func proposeShielding( + accountIndex: Int, shieldingThreshold: Zatoshi, - memoBytes: MemoBytes?, - from accountIndex: Int - ) async throws -> ZcashTransaction.Overview { - let txId = try await createShieldingSpend( - spendingKey: spendingKey, - shieldingThreshold: shieldingThreshold, + memoBytes: MemoBytes? + ) async throws -> Proposal { + let proposal = try await rustBackend.proposeShielding( + account: Int32(accountIndex), memo: memoBytes, - accountIndex: accountIndex + shieldingThreshold: shieldingThreshold ) - - logger.debug("transaction id: \(txId)") - return try await repository.find(rawID: txId) + + return Proposal(inner: proposal) } - func createShieldingSpend( - spendingKey: UnifiedSpendingKey, - shieldingThreshold: Zatoshi, - memo: MemoBytes?, - accountIndex: Int - ) async throws -> Data { + func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) async throws -> [ZcashTransaction.Overview] { guard ensureParams(spend: self.spendParamsURL, output: self.outputParamsURL) else { - throw ZcashError.walletTransEncoderShieldFundsMissingSaplingParams + throw ZcashError.walletTransEncoderCreateTransactionMissingSaplingParams } - // 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, + proposal: proposal.inner, usk: spendingKey ) - return txId + logger.debug("transaction id: \(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 afbe16a66..c512698d4 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: [ZcashTransaction.Overview]! + var createProposedTransactionsProposalSpendingKeyClosure: ((Proposal, UnifiedSpendingKey) async throws -> [ZcashTransaction.Overview])? + + func createProposedTransactions(proposal: Proposal, spendingKey: UnifiedSpendingKey) async throws -> [ZcashTransaction.Overview] { + 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?