[#1367] Send 0 funds only within shielded transaction

- Closes #1367
- Plus slightly refactored SendForm composable to two functions
- Changelog update
This commit is contained in:
Honza Rychnovský 2024-04-23 08:39:40 +02:00 committed by GitHub
parent b00c807df1
commit fb5d446bab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 118 additions and 76 deletions

View File

@ -9,6 +9,9 @@ directly impact users rather than highlighting other key architectural updates.*
## [Unreleased] ## [Unreleased]
### Fixed
- Sending zero funds is allowed only for shielded recipient address type
## [0.2.0 (609)] - 2024-04-18 ## [0.2.0 (609)] - 2024-04-18
### Added ### Added

View File

@ -128,7 +128,7 @@ class SendViewTestSetup(
// TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260 // TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260
}, },
setAmountState = {}, setAmountState = {},
amountState = AmountState.new(context, "", monetarySeparators), amountState = AmountState.new(context, monetarySeparators, "", false),
setMemoState = {}, setMemoState = {},
memoState = MemoState.new(""), memoState = MemoState.new(""),
walletRestoringState = WalletRestoringState.NONE, walletRestoringState = WalletRestoringState.NONE,

View File

@ -8,6 +8,7 @@ import androidx.activity.compose.BackHandler
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@ -21,6 +22,7 @@ import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.proposeSend import cash.z.ecc.android.sdk.model.proposeSend
import cash.z.ecc.android.sdk.model.toZecString import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.compose.BalanceState import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState import co.electriccoin.zcash.ui.common.model.WalletRestoringState
@ -95,7 +97,7 @@ internal fun WrapSend(
) )
} }
@Suppress("LongParameterList", "LongMethod") @Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod")
@VisibleForTesting @VisibleForTesting
@Composable @Composable
internal fun WrapSend( internal fun WrapSend(
@ -140,14 +142,28 @@ internal fun WrapSend(
// Amount computation: // Amount computation:
val (amountState, setAmountState) = val (amountState, setAmountState) =
rememberSaveable(stateSaver = AmountState.Saver) { rememberSaveable(stateSaver = AmountState.Saver) {
// Default amount state
mutableStateOf( mutableStateOf(
AmountState.new( AmountState.new(
context = context, context = context,
value = zecSend?.amount?.toZecString() ?: "", value = zecSend?.amount?.toZecString() ?: "",
monetarySeparators = monetarySeparators monetarySeparators = monetarySeparators,
isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false
) )
) )
} }
// New amount state based on the recipient address type (e.g. shielded supports zero funds sending and
// transparent not)
LaunchedEffect(key1 = recipientAddressState) {
setAmountState(
AmountState.new(
context = context,
isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false,
monetarySeparators = monetarySeparators,
value = amountState.value
)
)
}
// Memo computation: // Memo computation:
val (memoState, setMemoState) = val (memoState, setMemoState) =
@ -155,12 +171,12 @@ internal fun WrapSend(
mutableStateOf(MemoState.new(zecSend?.memo?.value ?: "")) mutableStateOf(MemoState.new(zecSend?.memo?.value ?: ""))
} }
// Clearing form if required form the previous navigation destination // Clearing form from the previous navigation destination if required
if (sendArguments?.clearForm == true) { if (sendArguments?.clearForm == true) {
setSendStage(SendStage.Form) setSendStage(SendStage.Form)
setZecSend(null) setZecSend(null)
setRecipientAddressState(RecipientAddressState.new("", null)) setRecipientAddressState(RecipientAddressState.new("", null))
setAmountState(AmountState.new(context, "", monetarySeparators)) setAmountState(AmountState.new(context, monetarySeparators, "", false))
setMemoState(MemoState.new("")) setMemoState(MemoState.new(""))
} }

View File

@ -23,8 +23,9 @@ sealed class AmountState(
companion object { companion object {
fun new( fun new(
context: Context, context: Context,
monetarySeparators: MonetarySeparators,
value: String, value: String,
monetarySeparators: MonetarySeparators isTransparentRecipient: Boolean
): AmountState { ): AmountState {
// Validate raw input string // Validate raw input string
val validated = val validated =
@ -41,11 +42,11 @@ sealed class AmountState(
// Convert the input to Zatoshi type-safe amount representation // Convert the input to Zatoshi type-safe amount representation
val zatoshi = (Zatoshi.fromZecString(context, value, monetarySeparators)) val zatoshi = (Zatoshi.fromZecString(context, value, monetarySeparators))
// Note that the 0 funds sending is supported for sending a memo-only transaction // Note that the zero funds sending is supported for sending a memo-only shielded transaction
return if (zatoshi == null) { return when {
Invalid(value) (zatoshi == null) -> Invalid(value)
} else { (zatoshi.value == 0L && isTransparentRecipient) -> Invalid(value)
Valid(value, zatoshi) else -> Valid(value, zatoshi)
} }
} }

View File

@ -264,8 +264,6 @@ private fun SendForm(
hasCameraFeature: Boolean, hasCameraFeature: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
// TODO [#1171]: Remove default MonetarySeparators locale // TODO [#1171]: Remove default MonetarySeparators locale
// TODO [#1171]: https://github.com/Electric-Coin-Company/zashi-android/issues/1171 // TODO [#1171]: https://github.com/Electric-Coin-Company/zashi-android/issues/1171
val monetarySeparators = MonetarySeparators.current(Locale.US) val monetarySeparators = MonetarySeparators.current(Locale.US)
@ -305,16 +303,17 @@ private fun SendForm(
SendFormAmountTextField( SendFormAmountTextField(
amountSate = amountState, amountSate = amountState,
setAmountState = setAmountState,
monetarySeparators = monetarySeparators,
focusManager = focusManager, focusManager = focusManager,
walletSnapshot = walletSnapshot,
imeAction = imeAction =
if (recipientAddressState.type == AddressType.Transparent) { if (recipientAddressState.type == AddressType.Transparent) {
ImeAction.Done ImeAction.Done
} else { } else {
ImeAction.Next ImeAction.Next
} },
isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false,
monetarySeparators = monetarySeparators,
setAmountState = setAmountState,
walletSnapshot = walletSnapshot,
) )
Spacer(Modifier.size(ZcashTheme.dimens.spacingDefault)) Spacer(Modifier.size(ZcashTheme.dimens.spacingDefault))
@ -342,68 +341,83 @@ private fun SendForm(
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
// Common conditions continuously checked for validity SendButton(amountState, memoState, monetarySeparators, onCreateZecSend, recipientAddressState, walletSnapshot)
val sendButtonEnabled = }
recipientAddressState.type !is AddressType.Invalid && }
recipientAddressState.address.isNotEmpty() &&
amountState is AmountState.Valid &&
amountState.value.isNotBlank() &&
walletSnapshot.canSpend(amountState.zatoshi) &&
// A valid memo is necessary only for non-transparent recipient
(recipientAddressState.type == AddressType.Transparent || memoState is MemoState.Correct)
Column( @Composable
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular), @Suppress("LongParameterList")
horizontalAlignment = Alignment.CenterHorizontally fun SendButton(
) { amountState: AmountState,
PrimaryButton( memoState: MemoState,
onClick = { monetarySeparators: MonetarySeparators,
// SDK side validations onCreateZecSend: (ZecSend) -> Unit,
val zecSendValidation = recipientAddressState: RecipientAddressState,
ZecSendExt.new( walletSnapshot: WalletSnapshot,
context = context, ) {
destinationString = recipientAddressState.address, val context = LocalContext.current
zecString = amountState.value,
// Take memo for a valid non-transparent receiver only
memoString =
if (recipientAddressState.type == AddressType.Transparent) {
""
} else {
memoState.text
},
monetarySeparators = monetarySeparators
)
when (zecSendValidation) { // Common conditions continuously checked for validity
is ZecSendExt.ZecSendValidation.Valid -> onCreateZecSend(zecSendValidation.zecSend) val sendButtonEnabled =
is ZecSendExt.ZecSendValidation.Invalid -> { recipientAddressState.type !is AddressType.Invalid &&
// We do not expect this validation to fail, so logging is enough here recipientAddressState.address.isNotEmpty() &&
// An error popup could be reasonable here as well amountState is AmountState.Valid &&
Twig.warn { "Send failed with: ${zecSendValidation.validationErrors}" } amountState.value.isNotBlank() &&
} walletSnapshot.canSpend(amountState.zatoshi) &&
// A valid memo is necessary only for non-transparent recipient
(recipientAddressState.type == AddressType.Transparent || memoState is MemoState.Correct)
Column(
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular),
horizontalAlignment = Alignment.CenterHorizontally
) {
PrimaryButton(
onClick = {
// SDK side validations
val zecSendValidation =
ZecSendExt.new(
context = context,
destinationString = recipientAddressState.address,
zecString = amountState.value,
// Take memo for a valid non-transparent receiver only
memoString =
if (recipientAddressState.type == AddressType.Transparent) {
""
} else {
memoState.text
},
monetarySeparators = monetarySeparators
)
when (zecSendValidation) {
is ZecSendExt.ZecSendValidation.Valid -> onCreateZecSend(zecSendValidation.zecSend)
is ZecSendExt.ZecSendValidation.Invalid -> {
// We do not expect this validation to fail, so logging is enough here
// An error popup could be reasonable here as well
Twig.warn { "Send failed with: ${zecSendValidation.validationErrors}" }
} }
}, }
text = stringResource(id = R.string.send_create), },
enabled = sendButtonEnabled, text = stringResource(id = R.string.send_create),
modifier = enabled = sendButtonEnabled,
Modifier modifier =
.testTag(SendTag.SEND_FORM_BUTTON) Modifier
.fillMaxWidth() .testTag(SendTag.SEND_FORM_BUTTON)
) .fillMaxWidth()
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
BodySmall( BodySmall(
text = text =
stringResource( stringResource(
id = R.string.send_fee, id = R.string.send_fee,
// TODO [#1047]: Representing Zatoshi amount // TODO [#1047]: Representing Zatoshi amount
// TODO [#1047]: https://github.com/Electric-Coin-Company/zashi-android/issues/1047 // TODO [#1047]: https://github.com/Electric-Coin-Company/zashi-android/issues/1047
Zatoshi(DEFAULT_LESS_THAN_FEE).toZecString() Zatoshi(DEFAULT_LESS_THAN_FEE).toZecString()
), ),
textFontWeight = FontWeight.SemiBold textFontWeight = FontWeight.SemiBold
) )
}
} }
} }
@ -496,10 +510,11 @@ fun SendFormAddressTextField(
fun SendFormAmountTextField( fun SendFormAmountTextField(
amountSate: AmountState, amountSate: AmountState,
focusManager: FocusManager, focusManager: FocusManager,
imeAction: ImeAction,
isTransparentRecipient: Boolean,
monetarySeparators: MonetarySeparators, monetarySeparators: MonetarySeparators,
setAmountState: (AmountState) -> Unit, setAmountState: (AmountState) -> Unit,
walletSnapshot: WalletSnapshot, walletSnapshot: WalletSnapshot,
imeAction: ImeAction,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -540,7 +555,14 @@ fun SendFormAmountTextField(
FormTextField( FormTextField(
value = amountSate.value, value = amountSate.value,
onValueChange = { newValue -> onValueChange = { newValue ->
setAmountState(AmountState.new(context, newValue, monetarySeparators)) setAmountState(
AmountState.new(
context = context,
value = newValue,
monetarySeparators = monetarySeparators,
isTransparentRecipient = isTransparentRecipient
)
)
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
error = amountError, error = amountError,