diff --git a/CHANGELOG.md b/CHANGELOG.md index 20484429..3f34dce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ 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 + +## 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, + 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 +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. + +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/Example/ZcashLightClientSample/ZcashLightClientSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/ZcashLightClientSample/ZcashLightClientSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 03537a49..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,8 +176,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/zcash-hackworks/zcash-light-client-ffi", "state" : { - "revision" : "c90afd6cc092468e71810bc715ddb49be8210b75", - "version" : "0.5.1" + "revision" : "7c801be1f445402a433b32835a50d832e8a50437", + "version" : "0.6.0" } } ], diff --git a/Package.resolved b/Package.resolved index 8b8c4e8a..6ed2aaaf 100644 --- a/Package.resolved +++ b/Package.resolved @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/zcash-hackworks/zcash-light-client-ffi", "state" : { - "revision" : "c90afd6cc092468e71810bc715ddb49be8210b75", - "version" : "0.5.1" + "revision" : "7c801be1f445402a433b32835a50d832e8a50437", + "version" : "0.6.0" } } ], diff --git a/Package.swift b/Package.swift index 4e410e85..f86b18e9 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +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"), - .package(url: "https://github.com/zcash-hackworks/zcash-light-client-ffi", exact: "0.5.1") + .package(url: "https://github.com/zcash-hackworks/zcash-light-client-ffi", exact: "0.6.0") ], targets: [ .target( 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, diff --git a/Sources/ZcashLightClientKit/Model/Proposal.swift b/Sources/ZcashLightClientKit/Model/Proposal.swift new file mode 100644 index 00000000..c462b79c --- /dev/null +++ b/Sources/ZcashLightClientKit/Model/Proposal.swift @@ -0,0 +1,45 @@ +// +// 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 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 + acc + Zatoshi(Int64(step.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) + } +} 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 100c0944..fa3b4a05 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) @@ -619,8 +619,9 @@ actor ZcashRustBackend: ZcashRustBackendWelding { func proposeShielding( account: Int32, memo: MemoBytes?, - shieldingThreshold: Zatoshi - ) async throws -> FfiProposal { + shieldingThreshold: Zatoshi, + transparentReceiver: String? + ) async throws -> FfiProposal? { globalDBLock.lock() let proposal = zcashlc_propose_shielding( dbData.0, @@ -628,6 +629,7 @@ actor ZcashRustBackend: ZcashRustBackendWelding { account, memo?.bytes, UInt64(shieldingThreshold.amount), + transparentReceiver.map { [CChar]($0.utf8CString) }, networkType.networkId, minimumConfirmations, useZIP317Fees @@ -646,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 { @@ -823,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 4d30e496..8843dea1 100644 --- a/Sources/ZcashLightClientKit/Rust/ZcashRustBackendWelding.swift +++ b/Sources/ZcashLightClientKit/Rust/ZcashRustBackendWelding.swift @@ -212,23 +212,31 @@ 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. /// - 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/Synchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer.swift index f675d0da..e94013de 100644 --- a/Sources/ZcashLightClientKit/Synchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer.swift @@ -154,15 +154,70 @@ 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. + /// - 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? + ) 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. /// - 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( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, @@ -173,9 +228,11 @@ 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( spendingKey: UnifiedSpendingKey, memo: Memo, @@ -227,7 +284,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 @@ -251,7 +308,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 @@ -433,6 +490,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..1f2d31ae 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift @@ -70,6 +70,46 @@ 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, + transparentReceiver: TransparentAddress? = nil, + completion: @escaping (Result) -> Void + ) { + AsyncToClosureGateway.executeThrowingAction(completion) { + try await self.synchronizer.proposeShielding( + accountIndex: accountIndex, + shieldingThreshold: shieldingThreshold, + memo: memo, + transparentReceiver: transparentReceiver + ) + } + } + + public func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey, + completion: @escaping (Result, Error>) -> Void + ) { + AsyncToClosureGateway.executeThrowingAction(completion) { + try await self.synchronizer.createProposedTransactions(proposal: proposal, spendingKey: spendingKey) + } + } + + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") public func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, @@ -82,6 +122,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 ab3f286a..aa38be92 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift @@ -69,6 +69,43 @@ 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, + transparentReceiver: TransparentAddress? = nil + ) -> SinglePublisher { + AsyncToCombineGateway.executeThrowingAction() { + try await self.synchronizer.proposeShielding( + accountIndex: accountIndex, + shieldingThreshold: shieldingThreshold, + memo: memo, + transparentReceiver: transparentReceiver + ) + } + } + + public func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) -> SinglePublisher, Error> { + AsyncToCombineGateway.executeThrowingAction() { + try await self.synchronizer.createProposedTransactions(proposal: proposal, spendingKey: spendingKey) + } + } + + @available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.") public func sendToAddress( spendingKey: UnifiedSpendingKey, zatoshi: Zatoshi, @@ -80,6 +117,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, diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift index a6d84f77..a162a872 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift @@ -265,6 +265,84 @@ 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, + transparentReceiver: TransparentAddress? = nil + ) async throws -> Proposal? { + try throwIfUnprepared() + + let proposal = try await transactionEncoder.proposeShielding( + accountIndex: accountIndex, + shieldingThreshold: shieldingThreshold, + memoBytes: memo.asMemoBytes(), + transparentReceiver: transparentReceiver?.stringEncoded + ) + + 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 +390,21 @@ public class SDKSynchronizer: Synchronizer { throw ZcashError.synchronizerShieldFundsInsuficientTransparentFunds } - let transaction = try await transactionEncoder.createShieldingTransaction( - spendingKey: spendingKey, + guard let proposal = try await transactionEncoder.proposeShielding( + accountIndex: Int(spendingKey.account), shieldingThreshold: shieldingThreshold, memoBytes: memo.asMemoBytes(), - from: Int(spendingKey.account) + transparentReceiver: nil + ) else { throw ZcashError.synchronizerShieldFundsInsuficientTransparentFunds } + + 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 +425,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..6acfef78 100644 --- a/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift +++ b/Sources/ZcashLightClientKit/Transaction/TransactionEncoder.swift @@ -19,44 +19,58 @@ 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 - /// - 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). + /// - 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. /// - /// - 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, + /// 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. + /// - 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?, - from accountIndex: Int - ) async throws -> ZcashTransaction.Overview + transparentReceiver: String? + ) 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(s) fails. + /// + /// 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..31e4f796 100644 --- a/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift +++ b/Sources/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift @@ -54,93 +54,61 @@ 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, + + func proposeShielding( + accountIndex: Int, + shieldingThreshold: Zatoshi, memoBytes: MemoBytes?, - from accountIndex: Int - ) async throws -> Data { + transparentReceiver: String? = nil + ) async throws -> Proposal? { + guard let proposal = try await rustBackend.proposeShielding( + account: Int32(accountIndex), + memo: memoBytes, + shieldingThreshold: shieldingThreshold, + transparentReceiver: transparentReceiver + ) else { return nil } + + 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, + let txIds = try await rustBackend.createProposedTransactions( + 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) - } + logger.debug("transaction ids: \(txIds)") - 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 + var txs: [ZcashTransaction.Overview] = [] + + for txId in txIds { + txs.append(try await repository.find(rawID: txId)) } - // 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 txs } func submit( diff --git a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift index afbe16a6..156d078a 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 proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverThrowableError: Error? + var proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverCallsCount = 0 + var proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverCalled: Bool { + return proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverCallsCount > 0 + } + 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, transparentReceiver: TransparentAddress?) async throws -> Proposal? { + if let error = proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverThrowableError { + throw error + } + 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 proposeShieldingAccountIndexShieldingThresholdMemoTransparentReceiverReturnValue + } + } + + // 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? @@ -2756,67 +2828,67 @@ 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 } } - // 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 660aac76..f5f33521 100644 --- a/Tests/TestUtils/Stubs.swift +++ b/Tests/TestUtils/Stubs.swift @@ -84,8 +84,8 @@ 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.setCreateProposedTransactionProposalUskThrowableError(ZcashError.rustCreateToAddress("mocked error")) + await rustBackendMock.setProposeShieldingAccountMemoShieldingThresholdTransparentReceiverThrowableError(ZcashError.rustShieldFunds("mocked error")) + await rustBackendMock.setCreateProposedTransactionsProposalUskThrowableError(ZcashError.rustCreateToAddress("mocked error")) await rustBackendMock.setDecryptAndStoreTransactionTxBytesMinedHeightThrowableError(ZcashError.rustDecryptAndStoreTransaction("mock fail")) await rustBackendMock.setInitDataDbSeedClosure() { seed in