Expose APIs for working with transaction proposals

Closes Electric-Coin-Company/zcash-swift-wallet-sdk#1204.
This commit is contained in:
Jack Grigg 2024-02-21 00:54:34 +00:00
parent 2ef0e00385
commit 8145248285
9 changed files with 393 additions and 120 deletions

View File

@ -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

View File

@ -0,0 +1,18 @@
//
// Proposal.swift
//
//
// Created by Jack Grigg on 20/02/2024.
//
import Foundation
/// A data structure that describes a series of transactions to be created.
public struct Proposal: Equatable {
let inner: FfiProposal
/// Returns the total fee to be paid across all proposed transactions, in zatoshis.
public func totalFeeRequired() -> Zatoshi {
Zatoshi(Int64(inner.balance.feeRequired))
}
}

View File

@ -154,7 +154,53 @@ public protocol Synchronizer: AnyObject {
/// - Parameter accountIndex: the optional accountId whose address is of interest. By default, the first account is used.
/// - Returns the address or nil if account index is incorrect
func getTransparentAddress(accountIndex: Int) async throws -> TransparentAddress
/// Creates a proposal for transferring funds to the given recipient.
///
/// - Parameter accountIndex: the account from which to transfer funds.
/// - Parameter recipient: the recipient's address.
/// - Parameter amount: the amount to send in Zatoshi.
/// - Parameter memo: an optional memo to include as part of the proposal's transactions. Use `nil` when sending to transparent receivers otherwise the function will throw an error.
///
/// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws
/// `SynchronizerErrors.notPrepared`.
func proposeTransfer(
accountIndex: Int,
recipient: Recipient,
amount: Zatoshi,
memo: Memo?
) async throws -> Proposal
/// Creates a proposal for shielding any transparent funds received by the given account.
///
/// - Parameter accountIndex: the account for which to shield funds.
/// - Parameter shieldingThreshold: the minimum transparent balance required before a proposal will be created.
/// - Parameter memo: an optional memo to include as part of the proposal's transactions.
///
/// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws
/// `SynchronizerErrors.notPrepared`.
func proposeShielding(
accountIndex: Int,
shieldingThreshold: Zatoshi,
memo: Memo
) async throws -> Proposal
/// Creates the transactions in the given proposal.
///
/// - Parameter proposal: the proposal for which to create transactions.
/// - Parameter spendingKey: the `UnifiedSpendingKey` associated with the account for which the proposal was created.
///
/// Returns a stream of objects for the transactions that were created as part of the
/// proposal, indicating whether they were submitted to the network or if an error
/// occurred.
///
/// If `prepare()` hasn't already been called since creation of the synchronizer instance
/// or since the last wipe then this method throws `SynchronizerErrors.notPrepared`.
func createProposedTransactions(
proposal: Proposal,
spendingKey: UnifiedSpendingKey
) async throws -> AsyncThrowingStream<TransactionSubmitResult, Error>
/// Sends zatoshi.
/// - Parameter spendingKey: the `UnifiedSpendingKey` that allows spends to occur.
/// - Parameter zatoshi: the amount to send in Zatoshi.
@ -433,6 +479,19 @@ public enum RewindPolicy {
case quick
}
/// The result of submitting a transaction to the network.
///
/// - success: the transaction was successfully submitted to the mempool.
/// - grpcFailure: the transaction failed to reach the lightwalletd server.
/// - submitFailure: the transaction reached the lightwalletd server but failed to enter the mempool.
/// - notAttempted: the transaction was created and is in the local wallet, but was not submitted to the network.
public enum TransactionSubmitResult: Equatable {
case success(txId: Data)
case grpcFailure(txId: Data, error: LightWalletServiceError)
case submitFailure(txId: Data, code: Int, description: String)
case notAttempted(txId: Data)
}
extension InternalSyncStatus {
public static func == (lhs: InternalSyncStatus, rhs: InternalSyncStatus) -> Bool {
switch (lhs, rhs) {

View File

@ -70,6 +70,39 @@ extension ClosureSDKSynchronizer: ClosureSynchronizer {
}
}
public func proposeTransfer(
accountIndex: Int,
recipient: Recipient,
amount: Zatoshi,
memo: Memo?,
completion: @escaping (Result<Proposal, Error>) -> Void
) {
AsyncToClosureGateway.executeThrowingAction(completion) {
try await self.synchronizer.proposeTransfer(accountIndex: accountIndex, recipient: recipient, amount: amount, memo: memo)
}
}
public func proposeShielding(
accountIndex: Int,
shieldingThreshold: Zatoshi,
memo: Memo,
completion: @escaping (Result<Proposal, Error>) -> Void
) {
AsyncToClosureGateway.executeThrowingAction(completion) {
try await self.synchronizer.proposeShielding(accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, memo: memo)
}
}
public func createProposedTransactions(
proposal: Proposal,
spendingKey: UnifiedSpendingKey,
completion: @escaping (Result<AsyncThrowingStream<TransactionSubmitResult, Error>, Error>) -> Void
) {
AsyncToClosureGateway.executeThrowingAction(completion) {
try await self.synchronizer.createProposedTransactions(proposal: proposal, spendingKey: spendingKey)
}
}
public func sendToAddress(
spendingKey: UnifiedSpendingKey,
zatoshi: Zatoshi,

View File

@ -69,6 +69,36 @@ extension CombineSDKSynchronizer: CombineSynchronizer {
}
}
public func proposeTransfer(
accountIndex: Int,
recipient: Recipient,
amount: Zatoshi,
memo: Memo?
) -> SinglePublisher<Proposal, Error> {
AsyncToCombineGateway.executeThrowingAction() {
try await self.synchronizer.proposeTransfer(accountIndex: accountIndex, recipient: recipient, amount: amount, memo: memo)
}
}
public func proposeShielding(
accountIndex: Int,
shieldingThreshold: Zatoshi,
memo: Memo
) -> SinglePublisher<Proposal, Error> {
AsyncToCombineGateway.executeThrowingAction() {
try await self.synchronizer.proposeShielding(accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, memo: memo)
}
}
public func createProposedTransactions(
proposal: Proposal,
spendingKey: UnifiedSpendingKey
) -> SinglePublisher<AsyncThrowingStream<TransactionSubmitResult, Error>, Error> {
AsyncToCombineGateway.executeThrowingAction() {
try await self.synchronizer.createProposedTransactions(proposal: proposal, spendingKey: spendingKey)
}
}
public func sendToAddress(
spendingKey: UnifiedSpendingKey,
zatoshi: Zatoshi,

View File

@ -265,6 +265,78 @@ public class SDKSynchronizer: Synchronizer {
// MARK: Synchronizer methods
public func proposeTransfer(accountIndex: Int, recipient: Recipient, amount: Zatoshi, memo: Memo?) async throws -> Proposal {
try throwIfUnprepared()
if case Recipient.transparent = recipient, memo != nil {
throw ZcashError.synchronizerSendMemoToTransparentAddress
}
let proposal = try await transactionEncoder.proposeTransfer(
accountIndex: accountIndex,
recipient: recipient.stringEncoded,
amount: amount,
memoBytes: memo?.asMemoBytes()
)
return proposal
}
public func proposeShielding(accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo) async throws -> Proposal {
try throwIfUnprepared()
let proposal = try await transactionEncoder.proposeShielding(
accountIndex: accountIndex,
shieldingThreshold: shieldingThreshold,
memoBytes: memo.asMemoBytes()
)
return proposal
}
public func createProposedTransactions(
proposal: Proposal,
spendingKey: UnifiedSpendingKey
) async throws -> AsyncThrowingStream<TransactionSubmitResult, Error> {
try throwIfUnprepared()
try await SaplingParameterDownloader.downloadParamsIfnotPresent(
spendURL: initializer.spendParamsURL,
spendSourceURL: initializer.saplingParamsSourceURL.spendParamFileURL,
outputURL: initializer.outputParamsURL,
outputSourceURL: initializer.saplingParamsSourceURL.outputParamFileURL,
logger: logger
)
let transactions = try await transactionEncoder.createProposedTransactions(
proposal: proposal,
spendingKey: spendingKey
)
var iterator = transactions.makeIterator()
var submitFailed = false
return AsyncThrowingStream() {
guard let transaction = iterator.next() else { return nil }
if submitFailed {
return .notAttempted(txId: transaction.rawID)
} else {
let encodedTransaction = try transaction.encodedTransaction()
do {
try await self.transactionEncoder.submit(transaction: encodedTransaction)
return TransactionSubmitResult.success(txId: transaction.rawID)
} catch ZcashError.serviceSubmitFailed(let error) {
submitFailed = true
return TransactionSubmitResult.grpcFailure(txId: transaction.rawID, error: error)
} catch TransactionEncoderError.submitError(let code, let message) {
submitFailed = true
return TransactionSubmitResult.submitFailure(txId: transaction.rawID, code: code, description: message)
}
}
}
}
public func sendToAddress(
spendingKey: UnifiedSpendingKey,
zatoshi: Zatoshi,
@ -312,13 +384,20 @@ public class SDKSynchronizer: Synchronizer {
throw ZcashError.synchronizerShieldFundsInsuficientTransparentFunds
}
let transaction = try await transactionEncoder.createShieldingTransaction(
spendingKey: spendingKey,
let proposal = try await transactionEncoder.proposeShielding(
accountIndex: Int(spendingKey.account),
shieldingThreshold: shieldingThreshold,
memoBytes: memo.asMemoBytes(),
from: Int(spendingKey.account)
memoBytes: memo.asMemoBytes()
)
let transactions = try await transactionEncoder.createProposedTransactions(
proposal: proposal,
spendingKey: spendingKey
)
assert(transactions.count == 1, "Rust backend doesn't produce multiple transactions yet")
let transaction = transactions[0]
let encodedTx = try transaction.encodedTransaction()
try await transactionEncoder.submit(transaction: encodedTx)
@ -339,14 +418,21 @@ public class SDKSynchronizer: Synchronizer {
throw ZcashError.synchronizerSendMemoToTransparentAddress
}
let transaction = try await transactionEncoder.createTransaction(
spendingKey: spendingKey,
zatoshi: zatoshi,
to: recipient.stringEncoded,
memoBytes: memo?.asMemoBytes(),
from: Int(spendingKey.account)
let proposal = try await transactionEncoder.proposeTransfer(
accountIndex: Int(spendingKey.account),
recipient: recipient.stringEncoded,
amount: zatoshi,
memoBytes: memo?.asMemoBytes()
)
let transactions = try await transactionEncoder.createProposedTransactions(
proposal: proposal,
spendingKey: spendingKey
)
assert(transactions.count == 1, "Rust backend doesn't produce multiple transactions yet")
let transaction = transactions[0]
let encodedTransaction = try transaction.encodedTransaction()
try await transactionEncoder.submit(transaction: encodedTransaction)

View File

@ -19,44 +19,50 @@ public enum TransactionEncoderError: Error {
}
protocol TransactionEncoder {
/// Creates a transaction, throwing an exception whenever things are missing. When the provided wallet implementation
/// doesn't throw an exception, we wrap the issue into a descriptive exception ourselves (rather than using
/// double-bangs for things).
/// Creates a proposal for transferring funds to the given recipient.
///
/// - Parameters:
/// - Parameter spendingKey: a `UnifiedSpendingKey` containing the spending key
/// - Parameter zatoshi: the amount to send in `Zatoshi`
/// - Parameter to: string containing the recipient address
/// - Parameter MemoBytes: string containing the memo (optional)
/// - Parameter accountIndex: index of the account that will be used to send the funds
/// - Parameter accountIndex: the account from which to transfer funds.
/// - Parameter recipient: string containing the recipient's address.
/// - Parameter amount: the amount to send in Zatoshi.
/// - Parameter memoBytes: an optional memo to include as part of the proposal's transactions. Use `nil` when sending to transparent receivers otherwise the function will throw an error.
///
/// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws
/// `SynchronizerErrors.notPrepared`.
func proposeTransfer(
accountIndex: Int,
recipient: String,
amount: Zatoshi,
memoBytes: MemoBytes?
) async throws -> Proposal
/// Creates a proposal for shielding any transparent funds received by the given account.
///
/// - Parameter accountIndex: the account for which to shield funds.
/// - Parameter shieldingThreshold: the minimum transparent balance required before a proposal will be created.
/// - Parameter memoBytes: an optional memo to include as part of the proposal's transactions.
///
/// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws
/// `SynchronizerErrors.notPrepared`.
func proposeShielding(
accountIndex: Int,
shieldingThreshold: Zatoshi,
memoBytes: MemoBytes?
) async throws -> Proposal
/// Creates the transactions in the given proposal.
///
/// - Parameter proposal: the proposal for which to create transactions.
/// - Parameter spendingKey: the `UnifiedSpendingKey` associated with the account for which the proposal was created.
/// - Throws:
/// - `walletTransEncoderCreateTransactionMissingSaplingParams` if the sapling parameters aren't downloaded.
/// - Some `ZcashError.rust*` if the creation of transaction fails.
func createTransaction(
spendingKey: UnifiedSpendingKey,
zatoshi: Zatoshi,
to address: String,
memoBytes: MemoBytes?,
from accountIndex: Int
) async throws -> ZcashTransaction.Overview
/// Creates a transaction that will attempt to shield transparent funds that are present on the blocks cache .throwing
/// an exception whenever things are missing. When the provided wallet implementation doesn't throw an exception,
/// we wrap the issue into a descriptive exception ourselves (rather than using double-bangs for things).
/// - Some `ZcashError.rust*` if the creation of transaction(s) fails.
///
/// - Parameters:
/// - Parameter spendingKey: `UnifiedSpendingKey` to spend the UTXOs
/// - Parameter memoBytes: containing the memo (optional)
/// - Parameter accountIndex: index of the account that will be used to send the funds
/// - Throws:
/// - `walletTransEncoderShieldFundsMissingSaplingParams` if the sapling parameters aren't downloaded.
/// - Some `ZcashError.rust*` if the creation of transaction fails.
func createShieldingTransaction(
spendingKey: UnifiedSpendingKey,
shieldingThreshold: Zatoshi,
memoBytes: MemoBytes?,
from accountIndex: Int
) async throws -> ZcashTransaction.Overview
/// If `prepare()` hasn't already been called since creation of the synchronizer instance or since the last wipe then this method throws
/// `SynchronizerErrors.notPrepared`.
func createProposedTransactions(
proposal: Proposal,
spendingKey: UnifiedSpendingKey
) async throws -> [ZcashTransaction.Overview]
/// submits a transaction to the Zcash peer-to-peer network.
/// - Parameter transaction: a transaction overview

View File

@ -54,93 +54,52 @@ class WalletTransactionEncoder: TransactionEncoder {
logger: initializer.logger
)
}
func createTransaction(
spendingKey: UnifiedSpendingKey,
zatoshi: Zatoshi,
to address: String,
memoBytes: MemoBytes?,
from accountIndex: Int
) async throws -> ZcashTransaction.Overview {
let txId = try await createSpend(
spendingKey: spendingKey,
zatoshi: zatoshi,
to: address,
memoBytes: memoBytes,
from: accountIndex
func proposeTransfer(
accountIndex: Int,
recipient: String,
amount: Zatoshi,
memoBytes: MemoBytes?
) async throws -> Proposal {
let proposal = try await rustBackend.proposeTransfer(
account: Int32(accountIndex),
to: recipient,
value: amount.amount,
memo: memoBytes
)
logger.debug("transaction id: \(txId)")
return try await repository.find(rawID: txId)
return Proposal(inner: proposal)
}
func createSpend(
spendingKey: UnifiedSpendingKey,
zatoshi: Zatoshi,
to address: String,
memoBytes: MemoBytes?,
from accountIndex: Int
) async throws -> Data {
func proposeShielding(
accountIndex: Int,
shieldingThreshold: Zatoshi,
memoBytes: MemoBytes?
) async throws -> Proposal {
let proposal = try await rustBackend.proposeShielding(
account: Int32(accountIndex),
memo: memoBytes,
shieldingThreshold: shieldingThreshold
)
return Proposal(inner: proposal)
}
func createProposedTransactions(
proposal: Proposal,
spendingKey: UnifiedSpendingKey
) async throws -> [ZcashTransaction.Overview] {
guard ensureParams(spend: self.spendParamsURL, output: self.outputParamsURL) else {
throw ZcashError.walletTransEncoderCreateTransactionMissingSaplingParams
}
// TODO: Expose the proposal in a way that enables querying its fee.
let proposal = try await rustBackend.proposeTransfer(
account: Int32(spendingKey.account),
to: address,
value: zatoshi.amount,
memo: memoBytes
)
let txId = try await rustBackend.createProposedTransaction(
proposal: proposal,
proposal: proposal.inner,
usk: spendingKey
)
return txId
}
func createShieldingTransaction(
spendingKey: UnifiedSpendingKey,
shieldingThreshold: Zatoshi,
memoBytes: MemoBytes?,
from accountIndex: Int
) async throws -> ZcashTransaction.Overview {
let txId = try await createShieldingSpend(
spendingKey: spendingKey,
shieldingThreshold: shieldingThreshold,
memo: memoBytes,
accountIndex: accountIndex
)
logger.debug("transaction id: \(txId)")
return try await repository.find(rawID: txId)
}
func createShieldingSpend(
spendingKey: UnifiedSpendingKey,
shieldingThreshold: Zatoshi,
memo: MemoBytes?,
accountIndex: Int
) async throws -> Data {
guard ensureParams(spend: self.spendParamsURL, output: self.outputParamsURL) else {
throw ZcashError.walletTransEncoderShieldFundsMissingSaplingParams
}
// TODO: Expose the proposal in a way that enables querying its fee.
let proposal = try await rustBackend.proposeShielding(
account: Int32(spendingKey.account),
memo: memo,
shieldingThreshold: shieldingThreshold
)
let txId = try await rustBackend.createProposedTransaction(
proposal: proposal,
usk: spendingKey
)
return txId
return [try await repository.find(rawID: txId)]
}
func submit(

View File

@ -1397,6 +1397,78 @@ class SynchronizerMock: Synchronizer {
}
}
// MARK: - proposeTransfer
var proposeTransferAccountIndexRecipientAmountMemoThrowableError: Error?
var proposeTransferAccountIndexRecipientAmountMemoCallsCount = 0
var proposeTransferAccountIndexRecipientAmountMemoCalled: Bool {
return proposeTransferAccountIndexRecipientAmountMemoCallsCount > 0
}
var proposeTransferAccountIndexRecipientAmountMemoReceivedArguments: (accountIndex: Int, recipient: Recipient, amount: Zatoshi, memo: Memo?)?
var proposeTransferAccountIndexRecipientAmountMemoReturnValue: Proposal!
var proposeTransferAccountIndexRecipientAmountMemoClosure: ((Int, Recipient, Zatoshi, Memo?) async throws -> Proposal)?
func proposeTransfer(accountIndex: Int, recipient: Recipient, amount: Zatoshi, memo: Memo?) async throws -> Proposal {
if let error = proposeTransferAccountIndexRecipientAmountMemoThrowableError {
throw error
}
proposeTransferAccountIndexRecipientAmountMemoCallsCount += 1
proposeTransferAccountIndexRecipientAmountMemoReceivedArguments = (accountIndex: accountIndex, recipient: recipient, amount: amount, memo: memo)
if let closure = proposeTransferAccountIndexRecipientAmountMemoClosure {
return try await closure(accountIndex, recipient, amount, memo)
} else {
return proposeTransferAccountIndexRecipientAmountMemoReturnValue
}
}
// MARK: - proposeShielding
var proposeShieldingAccountIndexShieldingThresholdMemoThrowableError: Error?
var proposeShieldingAccountIndexShieldingThresholdMemoCallsCount = 0
var proposeShieldingAccountIndexShieldingThresholdMemoCalled: Bool {
return proposeShieldingAccountIndexShieldingThresholdMemoCallsCount > 0
}
var proposeShieldingAccountIndexShieldingThresholdMemoReceivedArguments: (accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo)?
var proposeShieldingAccountIndexShieldingThresholdMemoReturnValue: Proposal!
var proposeShieldingAccountIndexShieldingThresholdMemoClosure: ((Int, Zatoshi, Memo) async throws -> Proposal)?
func proposeShielding(accountIndex: Int, shieldingThreshold: Zatoshi, memo: Memo) async throws -> Proposal {
if let error = proposeShieldingAccountIndexShieldingThresholdMemoThrowableError {
throw error
}
proposeShieldingAccountIndexShieldingThresholdMemoCallsCount += 1
proposeShieldingAccountIndexShieldingThresholdMemoReceivedArguments = (accountIndex: accountIndex, shieldingThreshold: shieldingThreshold, memo: memo)
if let closure = proposeShieldingAccountIndexShieldingThresholdMemoClosure {
return try await closure(accountIndex, shieldingThreshold, memo)
} else {
return proposeShieldingAccountIndexShieldingThresholdMemoReturnValue
}
}
// MARK: - createProposedTransactions
var createProposedTransactionsProposalSpendingKeyThrowableError: Error?
var createProposedTransactionsProposalSpendingKeyCallsCount = 0
var createProposedTransactionsProposalSpendingKeyCalled: Bool {
return createProposedTransactionsProposalSpendingKeyCallsCount > 0
}
var createProposedTransactionsProposalSpendingKeyReceivedArguments: (proposal: Proposal, spendingKey: UnifiedSpendingKey)?
var createProposedTransactionsProposalSpendingKeyReturnValue: AsyncThrowingStream<TransactionSubmitResult, Error>!
var createProposedTransactionsProposalSpendingKeyClosure: ((Proposal, UnifiedSpendingKey) async throws -> AsyncThrowingStream<TransactionSubmitResult, Error>)?
func createProposedTransactions(proposal: Proposal, spendingKey: UnifiedSpendingKey) async throws -> AsyncThrowingStream<TransactionSubmitResult, Error> {
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?