Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose APIs for working with transaction proposals #1382

Merged
merged 10 commits into from
Mar 8, 2024
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
],
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
],
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
59 changes: 59 additions & 0 deletions Sources/ZcashLightClientKit/ClosureSynchronizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,64 @@ public protocol ClosureSynchronizer {
func getUnifiedAddress(accountIndex: Int, completion: @escaping (Result<UnifiedAddress, Error>) -> Void)
func getTransparentAddress(accountIndex: Int, completion: @escaping (Result<TransparentAddress, Error>) -> 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<Proposal, Error>) -> 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<Proposal?, Error>) -> 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<AsyncThrowingStream<TransactionSubmitResult, Error>, Error>) -> Void
)

@available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.")
func sendToAddress(
spendingKey: UnifiedSpendingKey,
zatoshi: Zatoshi,
Expand All @@ -44,6 +102,7 @@ public protocol ClosureSynchronizer {
completion: @escaping (Result<ZcashTransaction.Overview, Error>) -> Void
)

@available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.")
func shieldFunds(
spendingKey: UnifiedSpendingKey,
memo: Memo,
Expand Down
56 changes: 56 additions & 0 deletions Sources/ZcashLightClientKit/CombineSynchronizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,69 @@ public protocol CombineSynchronizer {
func getUnifiedAddress(accountIndex: Int) -> SinglePublisher<UnifiedAddress, Error>
func getTransparentAddress(accountIndex: Int) -> SinglePublisher<TransparentAddress, Error>

/// 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<Proposal, Error>

/// 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<Proposal?, Error>

/// 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<AsyncThrowingStream<TransactionSubmitResult, Error>, Error>

@available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.")
func sendToAddress(
spendingKey: UnifiedSpendingKey,
zatoshi: Zatoshi,
toAddress: Recipient,
memo: Memo?
) -> SinglePublisher<ZcashTransaction.Overview, Error>

@available(*, deprecated, message: "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.")
func shieldFunds(
spendingKey: UnifiedSpendingKey,
memo: Memo,
Expand Down
45 changes: 45 additions & 0 deletions Sources/ZcashLightClientKit/Model/Proposal.swift
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would advise not to expose this publicly.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have a suggestion for how to avoid exposing this publicly, I'm all ears. The constraint here is Zashi iOS using TCA, which needs to be able to construct mock data to be returned from its mock implementation of the Synchronizer protocol for use in e.g. preview of UIs (so we can't just return something that would be an error case).

Copy link
Contributor

@pacu pacu Feb 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: I don't know which things you've tried and which ones you haven't, but there are a few things I can think from the top of my head. I promise I'll look this deeper shortly. I might be still lacking context and this could be somewhat inaccurate or insufficient.

A. "Uncle bob's Clean code" approach

Treat the whole SDK as an external dependency under the principle of Bob Martin's clean code architecture. Look at the problem as you were Unstoppable or EDGE and you don't control the dependency. Wrap around it and have a shim that you Do control and use that instead.

B. Pointfree.co / TCA approach.

Make proposal on the SDK Protocol. The SDK returns an implementation of it. Then use a protocol witness or a mock implementation on Zashi to provide the mocked version that you need.

A and B

Make a protocol on Zashi for what the app expects of a proposal. This propocol contains a throwing toProposal() function.
Make an Adapter of the SDK's Proposal type that conforms to Zashi's.
Make a Mock implementation for testing on Zashi that throws whenever toProposal() is invoked. If by mistake this ever happens and a mocked proposal is sent in production you can see the error and flag it as critical in Crash reporting platform.

Or better, the testing code is never available in outside of the testing target.

/// 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)
}
}