Adjust `Synchronizer.proposeShielding` API

- Returns `null` when there are no funds to shield or the shielding
  threshold is not met.
- Throws an exception if there are funds to shield in more than one
  transparent receiver within the account.
- Has an optional parameter for specifying which transparent receiver
  to shield funds from.

Part of Electric-Coin-Company/zcash-android-wallet-sdk#680.
This commit is contained in:
Jack Grigg 2024-02-28 01:44:12 +00:00
parent e36bbdec7a
commit abffb3f9ee
17 changed files with 167 additions and 63 deletions

View File

@ -31,8 +31,9 @@ interface Backend {
suspend fun proposeShielding( suspend fun proposeShielding(
account: Int, account: Int,
shieldingThreshold: Long, shieldingThreshold: Long,
memo: ByteArray? = byteArrayOf() memo: ByteArray? = byteArrayOf(),
): ProposalUnsafe transparentReceiver: String? = null
): ProposalUnsafe?
suspend fun createProposedTransaction( suspend fun createProposedTransaction(
proposal: ProposalUnsafe, proposal: ProposalUnsafe,

View File

@ -308,19 +308,23 @@ class RustBackend private constructor(
override suspend fun proposeShielding( override suspend fun proposeShielding(
account: Int, account: Int,
shieldingThreshold: Long, shieldingThreshold: Long,
memo: ByteArray? memo: ByteArray?,
): ProposalUnsafe { transparentReceiver: String?
): ProposalUnsafe? {
return withContext(SdkDispatchers.DATABASE_IO) { return withContext(SdkDispatchers.DATABASE_IO) {
ProposalUnsafe.parse( proposeShielding(
proposeShielding( dataDbFile.absolutePath,
dataDbFile.absolutePath, account,
account, shieldingThreshold,
shieldingThreshold, memo ?: ByteArray(0),
memo ?: ByteArray(0), transparentReceiver,
networkId = networkId, networkId = networkId,
useZip317Fees = IS_USE_ZIP_317_FEES useZip317Fees = IS_USE_ZIP_317_FEES
)?.let {
ProposalUnsafe.parse(
it
) )
) }
} }
} }
@ -588,9 +592,10 @@ class RustBackend private constructor(
account: Int, account: Int,
shieldingThreshold: Long, shieldingThreshold: Long,
memo: ByteArray, memo: ByteArray,
transparentReceiver: String?,
networkId: Int, networkId: Int,
useZip317Fees: Boolean useZip317Fees: Boolean
): ByteArray ): ByteArray?
@JvmStatic @JvmStatic
@Suppress("LongParameterList") @Suppress("LongParameterList")

View File

@ -1482,6 +1482,7 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeSh
account: jint, account: jint,
shielding_threshold: jlong, shielding_threshold: jlong,
memo: JByteArray<'local>, memo: JByteArray<'local>,
transparent_receiver: JString<'local>,
network_id: jint, network_id: jint,
use_zip317_fees: jboolean, use_zip317_fees: jboolean,
) -> jbyteArray { ) -> jbyteArray {
@ -1494,10 +1495,36 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeSh
.map_err(|()| format_err!("Invalid shielding threshold, out of range"))?; .map_err(|()| format_err!("Invalid shielding threshold, out of range"))?;
let memo_bytes = env.convert_byte_array(memo).unwrap(); let memo_bytes = env.convert_byte_array(memo).unwrap();
let transparent_receiver =
match utils::java_nullable_string_to_rust(env, &transparent_receiver) {
None => Ok(None),
Some(addr) => match Address::decode(&network, &addr) {
None => Err(format_err!("Transparent receiver is for the wrong network")),
Some(addr) => match addr {
Address::Sapling(_) | Address::Unified(_) => Err(format_err!(
"Transparent receiver is not a transparent address"
)),
Address::Transparent(addr) => {
if db_data
.get_transparent_receivers(account)?
.contains_key(&addr)
{
Ok(Some(addr))
} else {
Err(format_err!(
"Transparent receiver does not belong to account {}",
u32::from(account),
))
}
}
},
},
}?;
let min_confirmations = 0; let min_confirmations = 0;
let min_confirmations_for_heights = NonZeroU32::new(1).unwrap(); let min_confirmations_for_heights = NonZeroU32::new(1).unwrap();
let from_addrs: Vec<TransparentAddress> = db_data let account_receivers = db_data
.get_target_and_anchor_heights(min_confirmations_for_heights) .get_target_and_anchor_heights(min_confirmations_for_heights)
.map_err(|e| format_err!("Error while fetching anchor height: {}", e)) .map_err(|e| format_err!("Error while fetching anchor height: {}", e))
.and_then(|opt_anchor| { .and_then(|opt_anchor| {
@ -1515,9 +1542,23 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeSh
e e
) )
}) })
})? })?;
.into_keys()
.collect(); let from_addrs = if let Some((addr, _)) = transparent_receiver.map_or_else(||
if account_receivers.len() > 1 {
Err(format_err!(
"Account has more than one transparent receiver with funds to shield; this is not yet supported by the SDK. Provide a specific transparent receiver to shield funds from."
))
} else {
Ok(account_receivers.iter().next().map(|(a, v)| (*a, *v)))
},
|addr| Ok(account_receivers.get(&addr).map(|value| (addr, *value)))
)?.filter(|(_, value)| *value >= shielding_threshold.into()) {
[addr]
} else {
// There are no transparent funds to shield; don't create a proposal.
return Ok(ptr::null_mut());
};
let memo = Memo::from_bytes(&memo_bytes).unwrap(); let memo = Memo::from_bytes(&memo_bytes).unwrap();

View File

@ -38,6 +38,10 @@ pub(crate) fn java_string_to_rust(env: &mut JNIEnv, jstring: &JString) -> String
.into() .into()
} }
pub(crate) fn java_nullable_string_to_rust(env: &mut JNIEnv, jstring: &JString) -> Option<String> {
(!jstring.is_null()).then(|| java_string_to_rust(env, jstring))
}
pub(crate) fn rust_bytes_to_java<'a>( pub(crate) fn rust_bytes_to_java<'a>(
env: &JNIEnv<'a>, env: &JNIEnv<'a>,
data: &[u8], data: &[u8],

View File

@ -129,10 +129,12 @@ class TestWallet(
synchronizer.getTransparentBalance(transparentAddress).let { walletBalance -> synchronizer.getTransparentBalance(transparentAddress).let { walletBalance ->
if (walletBalance.value > 0L) { if (walletBalance.value > 0L) {
synchronizer.createProposedTransactions( synchronizer.proposeShielding(shieldedSpendingKey.account, Zatoshi(100000))?.let {
synchronizer.proposeShielding(shieldedSpendingKey.account, Zatoshi(100000)), synchronizer.createProposedTransactions(
shieldedSpendingKey it,
) shieldedSpendingKey
)
}
} }
} }

View File

@ -68,10 +68,12 @@ class GetBalanceFragment : BaseDemoFragment<FragmentGetBalanceBinding>() {
Account.DEFAULT Account.DEFAULT
) )
sharedViewModel.synchronizerFlow.value?.let { synchronizer -> sharedViewModel.synchronizerFlow.value?.let { synchronizer ->
synchronizer.createProposedTransactions( synchronizer.proposeShielding(usk.account, Zatoshi(100000))?.let { it1 ->
synchronizer.proposeShielding(usk.account, Zatoshi(100000)), synchronizer.createProposedTransactions(
usk it1,
) usk
)
}
} }
} }
} }

View File

@ -230,12 +230,14 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch { viewModelScope.launch {
val spendingKey = spendingKey.filterNotNull().first() val spendingKey = spendingKey.filterNotNull().first()
kotlin.runCatching { kotlin.runCatching {
synchronizer.createProposedTransactions( synchronizer.proposeShielding(spendingKey.account, Zatoshi(100000))?.let {
synchronizer.proposeShielding(spendingKey.account, Zatoshi(100000)), synchronizer.createProposedTransactions(
spendingKey it,
) spendingKey
)
}
} }
.onSuccess { mutableSendState.value = SendState.Sent(it.toList()) } .onSuccess { it?.let { mutableSendState.value = SendState.Sent(it.toList()) } }
.onFailure { mutableSendState.value = SendState.Error(it) } .onFailure { mutableSendState.value = SendState.Error(it) }
} }
} else { } else {

View File

@ -132,10 +132,12 @@ class TestWallet(
Twig.debug { "FOUND utxo balance of total: $walletBalance" } Twig.debug { "FOUND utxo balance of total: $walletBalance" }
if (walletBalance.value > 0L) { if (walletBalance.value > 0L) {
synchronizer.createProposedTransactions( synchronizer.proposeShielding(spendingKey.account, Zatoshi(100000))?.let {
synchronizer.proposeShielding(spendingKey.account, Zatoshi(100000)), synchronizer.createProposedTransactions(
spendingKey it,
) spendingKey
)
}
} }
} }

View File

@ -89,8 +89,9 @@ internal class FakeRustBackend(
override suspend fun proposeShielding( override suspend fun proposeShielding(
account: Int, account: Int,
shieldingThreshold: Long, shieldingThreshold: Long,
memo: ByteArray? memo: ByteArray?,
): ProposalUnsafe { transparentReceiver: String?
): ProposalUnsafe? {
TODO("Not yet implemented") TODO("Not yet implemented")
} }

View File

@ -570,8 +570,9 @@ class SdkSynchronizer private constructor(
override suspend fun proposeShielding( override suspend fun proposeShielding(
account: Account, account: Account,
shieldingThreshold: Zatoshi, shieldingThreshold: Zatoshi,
memo: String memo: String,
): Proposal = txManager.proposeShielding(account, shieldingThreshold, memo) transparentReceiver: String?
): Proposal? = txManager.proposeShielding(account, shieldingThreshold, memo, transparentReceiver)
@Throws(TransactionEncoderException::class) @Throws(TransactionEncoderException::class)
override suspend fun createProposedTransactions( override suspend fun createProposedTransactions(
@ -636,7 +637,7 @@ class SdkSynchronizer private constructor(
message = "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.", message = "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.",
replaceWith = replaceWith =
ReplaceWith( ReplaceWith(
"createProposedTransactions(proposeShielding(usk.account, shieldingThreshold, memo), usk)" "proposeShielding(usk.account, shieldingThreshold, memo)?.let { createProposedTransactions(it, usk) }"
) )
) )
@Throws(TransactionEncoderException::class, TransactionSubmitException::class) @Throws(TransactionEncoderException::class, TransactionSubmitException::class)

View File

@ -193,12 +193,23 @@ interface Synchronizer {
* @param shieldingThreshold the minimum transparent balance required before a * @param shieldingThreshold the minimum transparent balance required before a
* proposal will be created. * proposal will be created.
* @param memo the optional memo to include as part of the proposal's transactions. * @param memo the optional memo to include as part of the proposal's transactions.
* @param transparentReceiver a specific transparent receiver within the account that
* should be the source of transparent funds. Default is
* null which will select whichever of the account's
* transparent receivers has funds to shield.
*
* @return the proposal, or null if the transparent balance that would be shielded is
* zero or below `shieldingThreshold`.
*
* @throws Exception if `transparentReceiver` is null and there are transparent funds
* in more than one of the account's transparent receivers.
*/ */
suspend fun proposeShielding( suspend fun proposeShielding(
account: Account, account: Account,
shieldingThreshold: Zatoshi, shieldingThreshold: Zatoshi,
memo: String = ZcashSdk.DEFAULT_SHIELD_FUNDS_MEMO_PREFIX memo: String = ZcashSdk.DEFAULT_SHIELD_FUNDS_MEMO_PREFIX,
): Proposal transparentReceiver: String? = null
): Proposal?
/** /**
* Creates the transactions in the given proposal. * Creates the transactions in the given proposal.
@ -247,7 +258,7 @@ interface Synchronizer {
message = "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.", message = "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.",
replaceWith = replaceWith =
ReplaceWith( ReplaceWith(
"createProposedTransactions(proposeShielding(usk.account, shieldingThreshold, memo), usk)" "proposeShielding(usk.account, shieldingThreshold, memo)?.let { createProposedTransactions(it, usk) }"
) )
) )
suspend fun shieldFunds( suspend fun shieldFunds(

View File

@ -35,8 +35,9 @@ internal interface TypesafeBackend {
suspend fun proposeShielding( suspend fun proposeShielding(
account: Account, account: Account,
shieldingThreshold: Long, shieldingThreshold: Long,
memo: ByteArray? = byteArrayOf() memo: ByteArray? = byteArrayOf(),
): Proposal transparentReceiver: String? = null
): Proposal?
suspend fun createProposedTransaction( suspend fun createProposedTransaction(
proposal: Proposal, proposal: Proposal,

View File

@ -54,15 +54,19 @@ internal class TypesafeBackendImpl(private val backend: Backend) : TypesafeBacke
override suspend fun proposeShielding( override suspend fun proposeShielding(
account: Account, account: Account,
shieldingThreshold: Long, shieldingThreshold: Long,
memo: ByteArray? memo: ByteArray?,
): Proposal = transparentReceiver: String?
Proposal.fromUnsafe( ): Proposal? =
backend.proposeShielding( backend.proposeShielding(
account.value, account.value,
shieldingThreshold, shieldingThreshold,
memo memo,
transparentReceiver
)?.let {
Proposal.fromUnsafe(
it
) )
) }
override suspend fun createProposedTransaction( override suspend fun createProposedTransaction(
proposal: Proposal, proposal: Proposal,

View File

@ -59,12 +59,23 @@ internal interface OutboundTransactionManager {
* @param shieldingThreshold the minimum transparent balance required before a * @param shieldingThreshold the minimum transparent balance required before a
* proposal will be created. * proposal will be created.
* @param memo the optional memo to include as part of the proposal's transactions. * @param memo the optional memo to include as part of the proposal's transactions.
* @param transparentReceiver a specific transparent receiver within the account that
* should be the source of transparent funds. Default is
* null which will select whichever of the account's
* transparent receivers has funds to shield.
*
* @return the proposal, or null if the transparent balance that would be shielded is
* zero or below `shieldingThreshold`.
*
* @throws Exception if `transparentReceiver` is null and there are transparent funds
* in more than one of the account's transparent receivers.
*/ */
suspend fun proposeShielding( suspend fun proposeShielding(
account: Account, account: Account,
shieldingThreshold: Zatoshi, shieldingThreshold: Zatoshi,
memo: String memo: String,
): Proposal transparentReceiver: String?
): Proposal?
/** /**
* Creates the transactions in the given proposal. * Creates the transactions in the given proposal.

View File

@ -53,8 +53,9 @@ internal class OutboundTransactionManagerImpl(
override suspend fun proposeShielding( override suspend fun proposeShielding(
account: Account, account: Account,
shieldingThreshold: Zatoshi, shieldingThreshold: Zatoshi,
memo: String memo: String,
): Proposal = encoder.proposeShielding(account, shieldingThreshold, memo.toByteArray()) transparentReceiver: String?
): Proposal? = encoder.proposeShielding(account, shieldingThreshold, memo.toByteArray(), transparentReceiver)
override suspend fun createProposedTransactions( override suspend fun createProposedTransactions(
proposal: Proposal, proposal: Proposal,

View File

@ -64,12 +64,23 @@ internal interface TransactionEncoder {
* @param shieldingThreshold the minimum transparent balance required before a * @param shieldingThreshold the minimum transparent balance required before a
* proposal will be created. * proposal will be created.
* @param memo the optional memo to include as part of the proposal's transactions. * @param memo the optional memo to include as part of the proposal's transactions.
* @param transparentReceiver a specific transparent receiver within the account that
* should be the source of transparent funds. Default is
* null which will select whichever of the account's
* transparent receivers has funds to shield.
*
* @return the proposal, or null if the transparent balance that would be shielded is
* zero or below `shieldingThreshold`.
*
* @throws Exception if `transparentReceiver` is null and there are transparent funds
* in more than one of the account's transparent receivers.
*/ */
suspend fun proposeShielding( suspend fun proposeShielding(
account: Account, account: Account,
shieldingThreshold: Zatoshi, shieldingThreshold: Zatoshi,
memo: ByteArray? = byteArrayOf() memo: ByteArray? = byteArrayOf(),
): Proposal transparentReceiver: String? = null
): Proposal?
/** /**
* Creates the transactions in the given proposal. * Creates the transactions in the given proposal.

View File

@ -1,5 +1,6 @@
package cash.z.ecc.android.sdk.internal.transaction package cash.z.ecc.android.sdk.internal.transaction
import cash.z.ecc.android.sdk.exception.SdkException
import cash.z.ecc.android.sdk.exception.TransactionEncoderException import cash.z.ecc.android.sdk.exception.TransactionEncoderException
import cash.z.ecc.android.sdk.ext.masked import cash.z.ecc.android.sdk.ext.masked
import cash.z.ecc.android.sdk.internal.SaplingParamTool import cash.z.ecc.android.sdk.internal.SaplingParamTool
@ -97,11 +98,12 @@ internal class TransactionEncoderImpl(
override suspend fun proposeShielding( override suspend fun proposeShielding(
account: Account, account: Account,
shieldingThreshold: Zatoshi, shieldingThreshold: Zatoshi,
memo: ByteArray? memo: ByteArray?,
): Proposal { transparentReceiver: String?
): Proposal? {
@Suppress("TooGenericExceptionCaught") @Suppress("TooGenericExceptionCaught")
return try { return try {
backend.proposeShielding(account, shieldingThreshold.value, memo) backend.proposeShielding(account, shieldingThreshold.value, memo, transparentReceiver)
} catch (t: Throwable) { } catch (t: Throwable) {
// TODO [#680]: if this error matches: Insufficient balance (have 0, need 1000 including fee) // 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 // then consider custom error that says no UTXOs existed to shield
@ -239,7 +241,9 @@ internal class TransactionEncoderImpl(
return try { return try {
saplingParamTool.ensureParams(saplingParamTool.properties.paramsDirectory) saplingParamTool.ensureParams(saplingParamTool.properties.paramsDirectory)
Twig.debug { "params exist! attempting to shield..." } Twig.debug { "params exist! attempting to shield..." }
val proposal = backend.proposeShielding(usk.account, 100000, memo) val proposal =
backend.proposeShielding(usk.account, 100000, memo)
?: throw SdkException("Insufficient balance (have 0, need 100000 including fee)", null)
backend.createProposedTransaction(proposal, usk) backend.createProposedTransaction(proposal, usk)
} catch (t: Throwable) { } catch (t: Throwable) {
// TODO [#680]: if this error matches: Insufficient balance (have 0, need 1000 including fee) // TODO [#680]: if this error matches: Insufficient balance (have 0, need 1000 including fee)