Expose APIs for working with transaction proposals

Closes Electric-Coin-Company/zcash-android-wallet-sdk#1359.
This commit is contained in:
Jack Grigg 2024-02-19 22:09:01 +00:00 committed by Honza
parent 04f1f47957
commit a27fbda8c0
10 changed files with 347 additions and 25 deletions

View File

@ -20,6 +20,13 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `Memo.MAX_MEMO_LENGTH_BYTES` is now available in public API
### Added
- 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.
- `Synchronizer.proposeTransfer`
- `Synchronizer.proposeShielding`
- `Synchronizer.createProposedTransactions`
- `WalletBalanceFixture` class with mock values that are supposed to be used only for testing purposes
- `Memo.countLength(memoString: String)` to count memo length in bytes
- `PersistableWallet.toSafeString` is a safe alternative for the regular [toString] function that prints only

View File

@ -42,8 +42,10 @@ import cash.z.ecc.android.sdk.internal.transaction.TransactionEncoderImpl
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
@ -68,6 +70,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -555,6 +558,46 @@ class SdkSynchronizer private constructor(
account
)
@Throws(TransactionEncoderException::class)
override suspend fun proposeTransfer(
account: Account,
recipient: String,
amount: Zatoshi,
memo: String
): Proposal = txManager.proposeTransfer(account, recipient, amount, memo)
@Throws(TransactionEncoderException::class)
override suspend fun proposeShielding(
account: Account,
memo: String
): Proposal = txManager.proposeShielding(account, memo)
@Throws(TransactionEncoderException::class)
override suspend fun createProposedTransactions(
proposal: Proposal,
usk: UnifiedSpendingKey
): Flow<TransactionSubmitResult> {
val transactions = txManager.createProposedTransactions(proposal, usk)
return flow {
var submitFailed = false
for (transaction in transactions) {
if (submitFailed) {
emit(TransactionSubmitResult.NotAttempted(transaction.txId))
} else {
val submitResult = txManager.submit(transaction)
when (submitResult) {
is TransactionSubmitResult.Success -> {}
else -> {
submitFailed = true
}
}
emit(submitResult)
}
}
}
}
@Throws(TransactionEncoderException::class, TransactionSubmitException::class)
override suspend fun sendToAddress(
usk: UnifiedSpendingKey,
@ -571,12 +614,15 @@ class SdkSynchronizer private constructor(
usk.account
)
if (txManager.submit(encodedTx)) {
when (txManager.submit(encodedTx)) {
is TransactionSubmitResult.Success -> {
return storage.findMatchingTransactionId(encodedTx.txId.byteArray)!!
} else {
}
else -> {
throw TransactionSubmitException()
}
}
}
@Throws(TransactionEncoderException::class, TransactionSubmitException::class)
override suspend fun shieldFunds(
@ -596,12 +642,15 @@ class SdkSynchronizer private constructor(
usk.account
)
if (txManager.submit(encodedTx)) {
when (txManager.submit(encodedTx)) {
is TransactionSubmitResult.Success -> {
return storage.findMatchingTransactionId(encodedTx.txId.byteArray)!!
} else {
}
else -> {
throw TransactionSubmitException()
}
}
}
override suspend fun refreshUtxos(
account: Account,

View File

@ -15,8 +15,10 @@ import cash.z.ecc.android.sdk.internal.model.ext.toBlockHeight
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
@ -167,6 +169,50 @@ interface Synchronizer {
*/
suspend fun getTransparentAddress(account: Account): String
/**
* Creates a proposal for transferring funds to the given recipient.
*
* @param account the account from which to transfer funds.
* @param recipient the recipient's address.
* @param amount the amount of zatoshi to send.
* @param memo the optional memo to include as part of the proposal's transactions.
*
* @return the proposal or an exception
*/
suspend fun proposeTransfer(
account: Account,
recipient: String,
amount: Zatoshi,
memo: String = ""
): Proposal
/**
* Creates a proposal for shielding any transparent funds received by the given account.
*
* @param account the account for which to shield funds.
* @param memo the optional memo to include as part of the proposal's transactions.
*/
suspend fun proposeShielding(
account: Account,
memo: String = ZcashSdk.DEFAULT_SHIELD_FUNDS_MEMO_PREFIX
): Proposal
/**
* Creates the transactions in the given proposal.
*
* @param proposal the proposal for which to create transactions.
* @param usk the unified spending key associated with the account for which the
* proposal was created.
*
* @return a flow of result 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.
*/
suspend fun createProposedTransactions(
proposal: Proposal,
usk: UnifiedSpendingKey
): Flow<TransactionSubmitResult>
/**
* Sends zatoshi.
*

View File

@ -26,14 +26,14 @@ internal interface TypesafeBackend {
@Suppress("LongParameterList")
suspend fun proposeTransfer(
usk: UnifiedSpendingKey,
account: Account,
to: String,
value: Long,
memo: ByteArray? = byteArrayOf()
): Proposal
suspend fun proposeShielding(
usk: UnifiedSpendingKey,
account: Account,
memo: ByteArray? = byteArrayOf()
): Proposal

View File

@ -37,14 +37,14 @@ internal class TypesafeBackendImpl(private val backend: Backend) : TypesafeBacke
@Suppress("LongParameterList")
override suspend fun proposeTransfer(
usk: UnifiedSpendingKey,
account: Account,
to: String,
value: Long,
memo: ByteArray?
): Proposal =
Proposal.fromUnsafe(
backend.proposeTransfer(
usk.account.value,
account.value,
to,
value,
memo
@ -52,12 +52,12 @@ internal class TypesafeBackendImpl(private val backend: Backend) : TypesafeBacke
)
override suspend fun proposeShielding(
usk: UnifiedSpendingKey,
account: Account,
memo: ByteArray?
): Proposal =
Proposal.fromUnsafe(
backend.proposeShielding(
usk.account.value,
account.value,
memo
)
)

View File

@ -2,7 +2,9 @@ package cash.z.ecc.android.sdk.internal.transaction
import cash.z.ecc.android.sdk.internal.model.EncodedTransaction
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.Zatoshi
@ -33,6 +35,48 @@ internal interface OutboundTransactionManager {
account: Account
): EncodedTransaction
/**
* Creates a proposal for transferring funds to the given recipient.
*
* @param account the account from which to transfer funds.
* @param recipient the recipient's address.
* @param amount the amount of zatoshi to send.
* @param memo the optional memo to include as part of the proposal's transactions.
*
* @return the proposal or an exception
*/
suspend fun proposeTransfer(
account: Account,
recipient: String,
amount: Zatoshi,
memo: String
): Proposal
/**
* Creates a proposal for shielding any transparent funds received by the given account.
*
* @param account the account for which to shield funds.
* @param memo the optional memo to include as part of the proposal's transactions.
*/
suspend fun proposeShielding(
account: Account,
memo: String
): Proposal
/**
* Creates the transactions in the given proposal.
*
* @param proposal the proposal for which to create transactions.
* @param usk the unified spending key associated with the account for which the
* proposal was created.
*
* @return the successfully encoded transactions or an exception
*/
suspend fun createProposedTransactions(
proposal: Proposal,
usk: UnifiedSpendingKey
): List<EncodedTransaction>
/**
* Submits the transaction represented by [encodedTransaction] to lightwalletd to broadcast to the
* network and, hopefully, include in the next block.
@ -41,7 +85,7 @@ internal interface OutboundTransactionManager {
* to lightwalletd.
* @return true if the transaction was successfully submitted to lightwalletd.
*/
suspend fun submit(encodedTransaction: EncodedTransaction): Boolean
suspend fun submit(encodedTransaction: EncodedTransaction): TransactionSubmitResult
/**
* Return true when the given address is a valid t-addr.

View File

@ -4,7 +4,9 @@ import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.ext.toHexReversed
import cash.z.ecc.android.sdk.internal.model.EncodedTransaction
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.lightwallet.client.LightWalletClient
@ -41,18 +43,40 @@ internal class OutboundTransactionManagerImpl(
}
}
override suspend fun submit(encodedTransaction: EncodedTransaction): Boolean {
override suspend fun proposeTransfer(
account: Account,
recipient: String,
amount: Zatoshi,
memo: String
): Proposal = encoder.proposeTransfer(account, recipient, amount, memo.toByteArray())
override suspend fun proposeShielding(
account: Account,
memo: String
): Proposal = encoder.proposeShielding(account, memo.toByteArray())
override suspend fun createProposedTransactions(
proposal: Proposal,
usk: UnifiedSpendingKey
): List<EncodedTransaction> = encoder.createProposedTransactions(proposal, usk)
override suspend fun submit(encodedTransaction: EncodedTransaction): TransactionSubmitResult {
return when (val response = service.submitTransaction(encodedTransaction.raw.byteArray)) {
is Response.Success -> {
if (response.result.code == 0) {
Twig.debug { "SUCCESS: submit transaction completed" }
true
TransactionSubmitResult.Success(encodedTransaction.txId)
} else {
Twig.debug {
"FAILURE! submit transaction ${encodedTransaction.txId.byteArray.toHexReversed()} " +
"completed with response: ${response.result.code}: ${response.result.message}"
}
false
TransactionSubmitResult.Failure(
encodedTransaction.txId,
false,
response.result.code,
response.result.message
)
}
}
@ -62,7 +86,12 @@ internal class OutboundTransactionManagerImpl(
response.description
}"
}
false
TransactionSubmitResult.Failure(
encodedTransaction.txId,
true,
response.code,
response.description
)
}
}
}

View File

@ -1,7 +1,9 @@
package cash.z.ecc.android.sdk.internal.transaction
import cash.z.ecc.android.sdk.internal.model.EncodedTransaction
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.Zatoshi
@ -38,6 +40,47 @@ internal interface TransactionEncoder {
memo: ByteArray? = byteArrayOf()
): EncodedTransaction
/**
* Creates a proposal for transferring funds to the given recipient.
*
* @param account the account from which to transfer funds.
* @param recipient the recipient's address.
* @param amount the amount of zatoshi to send.
* @param memo the optional memo to include as part of the proposal's transactions.
*
* @return the proposal or an exception
*/
suspend fun proposeTransfer(
account: Account,
recipient: String,
amount: Zatoshi,
memo: ByteArray? = byteArrayOf()
): Proposal
/**
* Creates a proposal for shielding any transparent funds sent to the given account.
*
* @param account the account for which to shield funds.
* @param memo the optional memo to include as part of the proposal's transactions.
*/
suspend fun proposeShielding(
account: Account,
memo: ByteArray? = byteArrayOf()
): Proposal
/**
* Creates the transactions in the given proposal.
*
* @param proposal the proposal to create.
* @param usk the unified spending key associated with the notes that will be spent.
*
* @return the successfully encoded transactions or an exception
*/
suspend fun createProposedTransactions(
proposal: Proposal,
usk: UnifiedSpendingKey
): List<EncodedTransaction>
/**
* Utility function to help with validation. This is not called during [createTransaction]
* because this class asserts that all validation is done externally by the UI, for now.

View File

@ -7,8 +7,10 @@ import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.TypesafeBackend
import cash.z.ecc.android.sdk.internal.model.EncodedTransaction
import cash.z.ecc.android.sdk.internal.repository.DerivedDataRepository
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.Zatoshi
@ -22,6 +24,7 @@ import cash.z.ecc.android.sdk.model.Zatoshi
* @property repository the repository that stores information about the transactions being created
* such as the raw bytes and raw txId.
*/
@Suppress("TooManyFunctions")
internal class TransactionEncoderImpl(
private val backend: TypesafeBackend,
private val saplingParamTool: SaplingParamTool,
@ -64,6 +67,79 @@ internal class TransactionEncoderImpl(
?: throw TransactionEncoderException.TransactionNotFoundException(transactionId)
}
override suspend fun proposeTransfer(
account: Account,
recipient: String,
amount: Zatoshi,
memo: ByteArray?
): Proposal {
Twig.debug {
"creating proposal to spend $amount zatoshi to" +
" ${recipient.masked()} with memo: ${memo?.decodeToString()}"
}
@Suppress("TooGenericExceptionCaught")
return try {
backend.proposeTransfer(
account,
recipient,
amount.value,
memo
)
} catch (t: Throwable) {
Twig.debug(t) { "Caught exception while creating proposal." }
throw t
}.also { result ->
Twig.debug { "result of proposeTransfer: $result" }
}
}
override suspend fun proposeShielding(
account: Account,
memo: ByteArray?
): Proposal {
@Suppress("TooGenericExceptionCaught")
return try {
backend.proposeShielding(account, memo)
} catch (t: Throwable) {
// TODO [#680]: if this error matches: Insufficient balance (have 0, need 1000 including fee)
// then consider custom error that says no UTXOs existed to shield
// TODO [#680]: https://github.com/zcash/zcash-android-wallet-sdk/issues/680
Twig.debug(t) { "proposeShielding failed" }
throw t
}.also { result ->
Twig.debug { "result of proposeShielding: $result" }
}
}
override suspend fun createProposedTransactions(
proposal: Proposal,
usk: UnifiedSpendingKey
): List<EncodedTransaction> {
Twig.debug {
"creating transactions for proposal"
}
@Suppress("TooGenericExceptionCaught")
val transactionId =
try {
saplingParamTool.ensureParams(saplingParamTool.properties.paramsDirectory)
Twig.debug { "params exist! attempting to send..." }
backend.createProposedTransaction(proposal, usk)
} catch (t: Throwable) {
Twig.debug(t) { "Caught exception while creating transaction." }
throw t
}.also { result ->
Twig.debug { "result of createProposedTransactions: $result" }
}
val tx =
repository.findEncodedTransactionByTxId(transactionId)
?: throw TransactionEncoderException.TransactionNotFoundException(transactionId)
return listOf(tx)
}
/**
* Utility function to help with validation. This is not called during [createTransaction]
* because this class asserts that all validation is done externally by the UI, for now.
@ -137,11 +213,9 @@ internal class TransactionEncoderImpl(
return try {
saplingParamTool.ensureParams(saplingParamTool.properties.paramsDirectory)
Twig.debug { "params exist! attempting to send..." }
// TODO [#1359]: Expose the proposal in a way that enables querying its fee.
// TODO [#1359]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/1359
val proposal =
backend.proposeTransfer(
usk,
usk.account,
toAddress,
amount.value,
memo
@ -163,9 +237,7 @@ internal class TransactionEncoderImpl(
return try {
saplingParamTool.ensureParams(saplingParamTool.properties.paramsDirectory)
Twig.debug { "params exist! attempting to shield..." }
// TODO [#1359]: Expose the proposal in a way that enables querying its fee.
// TODO [#1359]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/1359
val proposal = backend.proposeShielding(usk, memo)
val proposal = backend.proposeShielding(usk.account, memo)
backend.createProposedTransaction(proposal, usk)
} catch (t: Throwable) {
// TODO [#680]: if this error matches: Insufficient balance (have 0, need 1000 including fee)

View File

@ -0,0 +1,32 @@
package cash.z.ecc.android.sdk.model
/**
* A result object for a transaction that was created as part of a proposal, indicating
* whether it was submitted to the network or if an error occurred.
*/
sealed class TransactionSubmitResult {
/**
* The transaction was successfully submitted to the mempool.
*/
data class Success(val txId: FirstClassByteArray) : TransactionSubmitResult()
/**
* An error occurred while submitting the transaction.
*
* If `grpcError` is true, the transaction failed to reach the `lightwalletd` server.
* Otherwise, the transaction reached the `lightwalletd` server but failed to enter
* the mempool.
*/
data class Failure(
val txId: FirstClassByteArray,
val grpcError: Boolean,
val code: Int,
val description: String?
) : TransactionSubmitResult()
/**
* The transaction was created and is in the local wallet, but was not submitted to
* the network.
*/
data class NotAttempted(val txId: FirstClassByteArray) : TransactionSubmitResult()
}