Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-05 14:33:46 +04:00
parent 9477b2f497
commit 774898f560
3 changed files with 230 additions and 11 deletions

View file

@ -23,6 +23,8 @@ import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase
import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
@ -61,6 +63,7 @@ internal class SendDestinationModel @Inject constructor(
private val router: Router, private val router: Router,
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase, private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase, private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
private val isMemoRequiredUseCase: IsMemoRequiredUseCase,
private val getWalletsUseCase: GetWalletsUseCase, private val getWalletsUseCase: GetWalletsUseCase,
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase, private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase, private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
@ -295,6 +298,20 @@ internal class SendDestinationModel @Inject constructor(
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
memo = memo.orEmpty(), memo = memo.orEmpty(),
) )
val resolvedAddress =
(addressValidationResult.getOrNull() as? AddressValidation.Success.ValidNamedAddress)
?.blockchainAddress
?: address
// Ripple X-Address already embeds the destination tag, so memo is irrelevant for it
val isXAddress = addressValidationResult.getOrNull() == AddressValidation.Success.ValidXAddress
val isMemoRequired = memo.isNullOrBlank() &&
!isXAddress &&
addressValidationResult.isRight() &&
(uiState.value as? DestinationUM.Content)?.memoTextField != null &&
isMemoRequiredUseCase(
network = cryptoCurrency.network,
destinationAddress = resolvedAddress,
)
if (type != null) { if (type != null) {
analyticsEventHandler.send( analyticsEventHandler.send(
@ -308,12 +325,17 @@ internal class SendDestinationModel @Inject constructor(
} }
_uiState.update( _uiState.update(
SendDestinationValidationResultTransformer( SendDestinationValidationResultTransformer(
addressValidationResult, addressValidationResult = addressValidationResult,
memoValidationResult, memoValidationResult = memoValidationResult,
isMemoRequired = isMemoRequired,
), ),
) )
if (type != null) { if (type != null) {
autoNextFromRecipient(type, addressValidationResult.isRight(), memoValidationResult.isRight()) autoNextFromRecipient(
type = type,
isValidAddress = addressValidationResult.isRight(),
isValidMemo = isXAddress || memoValidationResult.isRight() && !isMemoRequired,
)
} }
}.saveIn(validationJobHolder) }.saveIn(validationJobHolder)
} }

View file

@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.transaction.error.AddressValidation import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.transaction.error.ValidateMemoError import com.tangem.domain.transaction.error.ValidateMemoError
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationTextFieldUM
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.impl.R
import com.tangem.utils.transformer.Transformer import com.tangem.utils.transformer.Transformer
@ -14,12 +15,14 @@ import kotlinx.collections.immutable.toPersistentList
internal class SendDestinationValidationResultTransformer( internal class SendDestinationValidationResultTransformer(
private val addressValidationResult: AddressValidationResult, private val addressValidationResult: AddressValidationResult,
private val memoValidationResult: Either<ValidateMemoError, Unit>, private val memoValidationResult: Either<ValidateMemoError, Unit>,
private val isMemoRequired: Boolean = false,
) : Transformer<DestinationUM> { ) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM { override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState val state = prevState as? DestinationUM.Content ?: return prevState
val shouldDisableMemo = shouldDisableMemo()
val isValidAddress = addressValidationResult.isRight() val isValidAddress = addressValidationResult.isRight()
val isValidMemo = memoValidationResult.isRight() val isValidMemo = shouldDisableMemo || memoValidationResult.isRight()
val addressErrorText = addressValidationResult.mapLeft { error -> val addressErrorText = addressValidationResult.mapLeft { error ->
when (error) { when (error) {
@ -30,23 +33,23 @@ internal class SendDestinationValidationResultTransformer(
} }
}.leftOrNull() }.leftOrNull()
val shouldDisableMemo = shouldDisableMemo()
val blockchainAddress = val blockchainAddress =
(addressValidationResult.getOrNull() as? AddressValidation.Success.ValidNamedAddress)?.blockchainAddress (addressValidationResult.getOrNull() as? AddressValidation.Success.ValidNamedAddress)?.blockchainAddress
val memoField = state.memoTextField val isMemoMissing = isMemoRequired && !shouldDisableMemo && state.memoTextField?.value.isNullOrBlank()
return state.copy( return state.copy(
isValidating = false, isValidating = false,
isPrimaryButtonEnabled = isValidAddress && isValidMemo, isPrimaryButtonEnabled = isValidAddress && isValidMemo && !isMemoMissing,
addressTextField = state.addressTextField.copy( addressTextField = state.addressTextField.copy(
error = addressErrorText?.let(::resourceReference), error = addressErrorText?.let(::resourceReference),
isError = state.addressTextField.value.isNotEmpty() && !isValidAddress, isError = state.addressTextField.value.isNotEmpty() && !isValidAddress,
blockchainAddress = blockchainAddress, blockchainAddress = blockchainAddress,
), ),
memoTextField = memoField?.copy( memoTextField = buildMemoField(
value = memoField.value.takeIf { !shouldDisableMemo }.orEmpty(), memoField = state.memoTextField,
isError = memoField.value.isNotEmpty() && !isValidMemo, isValidMemo = isValidMemo,
isEnabled = !shouldDisableMemo, isMemoMissing = isMemoMissing,
shouldDisableMemo = shouldDisableMemo,
), ),
recent = state.recent.map { recent -> recent = state.recent.map { recent ->
recent.copy(isVisible = !isValidAddress && (recent.isLoading || recent.title != TextReference.EMPTY)) recent.copy(isVisible = !isValidAddress && (recent.isLoading || recent.title != TextReference.EMPTY))
@ -58,6 +61,26 @@ internal class SendDestinationValidationResultTransformer(
) )
} }
private fun buildMemoField(
memoField: DestinationTextFieldUM.RecipientMemo?,
isValidMemo: Boolean,
isMemoMissing: Boolean,
shouldDisableMemo: Boolean,
): DestinationTextFieldUM.RecipientMemo? {
memoField ?: return null
val isMemoFormatError = memoField.value.isNotEmpty() && !isValidMemo
return memoField.copy(
value = memoField.value.takeIf { !shouldDisableMemo }.orEmpty(),
isError = isMemoFormatError || isMemoMissing,
error = if (isMemoMissing) {
resourceReference(R.string.send_validation_destination_tag_required_title)
} else {
resourceReference(R.string.send_memo_destination_tag_error)
},
isEnabled = !shouldDisableMemo,
)
}
/** Ripple X-Address contains memo, so memo field is unnecessary */ /** Ripple X-Address contains memo, so memo field is unnecessary */
private fun shouldDisableMemo(): Boolean { private fun shouldDisableMemo(): Boolean {
return addressValidationResult.isRight { it == AddressValidation.Success.ValidXAddress } return addressValidationResult.isRight { it == AddressValidation.Success.ValidXAddress }

View file

@ -0,0 +1,174 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import androidx.compose.foundation.text.KeyboardOptions
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.transaction.error.ValidateMemoError
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationTextFieldUM
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.impl.R
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class SendDestinationValidationResultTransformerTest {
private val validAddress: AddressValidationResult = AddressValidation.Success.Valid.right()
private val invalidAddress: AddressValidationResult = AddressValidation.Error.InvalidAddress.left()
private val xAddress: AddressValidationResult = AddressValidation.Success.ValidXAddress.right()
private val validMemo = Unit.right()
private val invalidMemo = ValidateMemoError.InvalidMemo.left()
private val formatErrorRef = resourceReference(R.string.send_memo_destination_tag_error)
private val tagRequiredRef = resourceReference(R.string.send_validation_destination_tag_required_title)
@Test
fun `GIVEN required memo is empty WHEN transform THEN tag required error shown and primary button disabled`() {
val result = transform(validAddress, validMemo, isMemoRequired = true, memo = "")
assertThat(result.isPrimaryButtonEnabled).isFalse()
assertThat(result.memoTextField?.isError).isTrue()
assertThat(result.memoTextField?.error).isEqualTo(tagRequiredRef)
}
@Test
fun `GIVEN required memo is filled with valid value WHEN transform THEN primary button enabled`() {
val result = transform(validAddress, validMemo, isMemoRequired = true, memo = "123")
assertThat(result.isPrimaryButtonEnabled).isTrue()
assertThat(result.memoTextField?.isError).isFalse()
}
@Test
fun `GIVEN required memo filled with whitespace only WHEN transform THEN tag required error shown and primary button disabled`() {
val result = transform(validAddress, validMemo, isMemoRequired = true, memo = " ")
assertThat(result.isPrimaryButtonEnabled).isFalse()
assertThat(result.memoTextField?.isError).isTrue()
assertThat(result.memoTextField?.error).isEqualTo(tagRequiredRef)
}
@Test
fun `GIVEN empty memo that is not required WHEN transform THEN field valid and primary button enabled`() {
val result = transform(validAddress, validMemo, isMemoRequired = false, memo = "")
assertThat(result.isPrimaryButtonEnabled).isTrue()
assertThat(result.memoTextField?.isError).isFalse()
}
@Test
fun `GIVEN entered memo with valid format WHEN transform THEN primary button enabled`() {
val result = transform(validAddress, validMemo, isMemoRequired = false, memo = "valid-memo")
assertThat(result.isPrimaryButtonEnabled).isTrue()
assertThat(result.memoTextField?.isError).isFalse()
}
@Test
fun `GIVEN entered memo with invalid format WHEN transform THEN format error shown and primary button disabled`() {
val result = transform(validAddress, invalidMemo, isMemoRequired = false, memo = "bad-memo")
assertThat(result.isPrimaryButtonEnabled).isFalse()
assertThat(result.memoTextField?.isError).isTrue()
assertThat(result.memoTextField?.error).isEqualTo(formatErrorRef)
}
@Test
fun `GIVEN invalid memo after prior tag required state WHEN transform THEN format error shown`() {
val staleState = contentState(memo = "bad-memo").let { state ->
state.copy(memoTextField = state.memoTextField?.copy(error = tagRequiredRef))
}
val result = SendDestinationValidationResultTransformer(
addressValidationResult = validAddress,
memoValidationResult = invalidMemo,
isMemoRequired = false,
).transform(staleState) as DestinationUM.Content
assertThat(result.memoTextField?.isError).isTrue()
assertThat(result.memoTextField?.error).isEqualTo(formatErrorRef)
}
@Test
fun `GIVEN invalid address WHEN transform THEN primary button disabled`() {
val result = transform(invalidAddress, validMemo, isMemoRequired = false, memo = "123")
assertThat(result.isPrimaryButtonEnabled).isFalse()
}
@Test
fun `GIVEN x-address with required memo WHEN transform THEN memo field disabled cleared and primary button enabled`() {
val result = transform(xAddress, validMemo, isMemoRequired = true, memo = "")
assertThat(result.memoTextField?.isEnabled).isFalse()
assertThat(result.memoTextField?.isError).isFalse()
assertThat(result.memoTextField?.value).isEmpty()
assertThat(result.isPrimaryButtonEnabled).isTrue()
}
@Test
fun `GIVEN x-address with stale invalid memo WHEN transform THEN memo ignored field disabled and primary button enabled`() {
val result = transform(xAddress, invalidMemo, isMemoRequired = false, memo = "stale-memo")
assertThat(result.memoTextField?.isEnabled).isFalse()
assertThat(result.memoTextField?.isError).isFalse()
assertThat(result.memoTextField?.value).isEmpty()
assertThat(result.isPrimaryButtonEnabled).isTrue()
}
@Test
fun `GIVEN network without memo field WHEN transform THEN memo field is null and primary button enabled`() {
val state = contentState(memo = "").copy(memoTextField = null)
val result = SendDestinationValidationResultTransformer(
addressValidationResult = validAddress,
memoValidationResult = validMemo,
isMemoRequired = false,
).transform(state) as DestinationUM.Content
assertThat(result.memoTextField).isNull()
assertThat(result.isPrimaryButtonEnabled).isTrue()
}
private fun transform(
address: AddressValidationResult,
memoResult: Either<ValidateMemoError, Unit>,
isMemoRequired: Boolean,
memo: String,
): DestinationUM.Content = SendDestinationValidationResultTransformer(
addressValidationResult = address,
memoValidationResult = memoResult,
isMemoRequired = isMemoRequired,
).transform(contentState(memo = memo)) as DestinationUM.Content
private fun contentState(memo: String) = DestinationUM.Content(
isPrimaryButtonEnabled = false,
addressTextField = DestinationTextFieldUM.RecipientAddress(
value = "0xRecipient",
keyboardOptions = KeyboardOptions.Default,
placeholder = TextReference.EMPTY,
label = TextReference.EMPTY,
isValuePasted = false,
),
memoTextField = DestinationTextFieldUM.RecipientMemo(
value = memo,
keyboardOptions = KeyboardOptions.Default,
placeholder = TextReference.EMPTY,
label = TextReference.EMPTY,
error = formatErrorRef,
disabledText = TextReference.EMPTY,
isEnabled = true,
isValuePasted = false,
),
recent = persistentListOf(),
wallets = persistentListOf(),
networkName = "Ethereum",
isRecentHidden = false,
)
}