Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-27 18:50:39 +03:00
parent bbcdc778fa
commit 8c6372e398
13 changed files with 136 additions and 82 deletions

View file

@ -105,4 +105,12 @@ internal object WalletsDomainModule {
dispatchers = dispatchers,
)
}
@Provides
@ViewModelScoped
fun providesValidateWalletMemoUseCase(
walletAddressServiceRepository: WalletAddressServiceRepository,
): ValidateWalletMemoUseCase {
return ValidateWalletMemoUseCase(walletAddressServiceRepository = walletAddressServiceRepository)
}
}

View file

@ -66,7 +66,12 @@ class DefaultTxHistoryRepository(
override fun getTxExploreUrl(txHash: String, networkId: Network.ID): String {
val blockchain = Blockchain.fromId(networkId.value)
return blockchain.getExploreTxUrl(txHash)
// TODO: Fix ton tx urls [REDACTED_TASK_KEY]
return if (blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet) {
""
} else {
blockchain.getExploreTxUrl(txHash)
}
}
private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet {

View file

@ -1,16 +1,19 @@
package com.tangem.data.wallets
import androidx.core.text.isDigitsOnly
import com.tangem.blockchain.blockchains.near.NearWalletManager
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.repository.WalletAddressServiceRepository
import java.math.BigInteger
class DefaultWalletAddressServiceRepository(
private val walletManagersFacade: WalletManagersFacade,
) : WalletAddressServiceRepository {
override suspend fun validate(userWalletId: UserWalletId, network: Network, address: String): Boolean {
override suspend fun validateAddress(userWalletId: UserWalletId, network: Network, address: String): Boolean {
val blockchain = Blockchain.fromId(network.id.value)
return if (blockchain.isNear()) {
@ -25,7 +28,43 @@ class DefaultWalletAddressServiceRepository(
}
}
override fun validateMemo(network: Network, memo: String): Boolean {
if (memo.isEmpty()) return true
return when (network.id.value) {
Blockchain.XRP.id -> {
val tag = memo.toLongOrNull()
tag != null && tag <= XRP_TAG_MAX_NUMBER
}
Blockchain.Stellar.id -> {
isAssignableXlmValue(memo)
}
else -> true
}
}
private fun Blockchain.isNear(): Boolean {
return this == Blockchain.Near || this == Blockchain.NearTestnet
}
private fun isAssignableXlmValue(value: String): Boolean {
return when {
value.isNotEmpty() && value.isDigitsOnly() -> {
try {
// from com.tangem.blockchain.blockchains.stellar.StellarMemo.toStellarSdkMemo
value.toBigInteger() in BigInteger.ZERO..Long.MAX_VALUE.toBigInteger() * 2.toBigInteger()
} catch (ex: NumberFormatException) {
false
}
}
else -> {
// from org.stellar.sdk.MemoText
value.toByteArray().size <= XLM_MEMO_MAX_LENGTH
}
}
}
companion object {
private const val XLM_MEMO_MAX_LENGTH = 28
private const val XRP_TAG_MAX_NUMBER = 4294967295
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.domain.transaction.error
sealed class GetFeeError {
object DataError : GetFeeError()
data class DataError(val cause: Throwable?) : GetFeeError()
object UnknownError : GetFeeError()
}

View file

@ -30,20 +30,26 @@ class GetFeeUseCase(
destination: String,
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Flow<Either<GetFeeError.DataError, TransactionFee>> {
): Flow<Either<GetFeeError, TransactionFee>> {
return flow {
val result = walletManagersFacade.getFee(
amount = convertCryptoCurrencyToAmount(cryptoCurrency, amount),
destination = destination,
userWalletId = userWalletId,
network = cryptoCurrency.network,
)
try {
val result = requireNotNull(
walletManagersFacade.getFee(
amount = convertCryptoCurrencyToAmount(cryptoCurrency, amount),
destination = destination,
userWalletId = userWalletId,
network = cryptoCurrency.network,
),
) { "Fee is null" }
val maybeFee = when (result) {
is Result.Success -> result.data.right()
else -> GetFeeError.DataError.left()
val maybeFee = when (result) {
is Result.Success -> result.data.right()
is Result.Failure -> GetFeeError.DataError(result.error).left()
}
emit(maybeFee)
} catch (e: Exception) {
emit(GetFeeError.DataError(e.cause).left())
}
emit(maybeFee)
}.flowOn(dispatcher.io)
}

View file

@ -8,5 +8,7 @@ import com.tangem.domain.wallets.models.UserWalletId
*/
interface WalletAddressServiceRepository {
suspend fun validate(userWalletId: UserWalletId, network: Network, address: String): Boolean
suspend fun validateAddress(userWalletId: UserWalletId, network: Network, address: String): Boolean
fun validateMemo(network: Network, memo: String): Boolean
}

View file

@ -21,7 +21,7 @@ class ValidateWalletAddressUseCase(
address: String,
): Either<Throwable, Boolean> = withContext(dispatchers.io) {
Either.catch {
walletAddressServiceRepository.validate(userWalletId, network, address)
walletAddressServiceRepository.validateAddress(userWalletId, network, address)
}
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.repository.WalletAddressServiceRepository
/**
* Use case for validating wallet memo.
*/
class ValidateWalletMemoUseCase(
private val walletAddressServiceRepository: WalletAddressServiceRepository,
) {
operator fun invoke(network: Network, memo: String): Either<Throwable, Boolean> = Either.catch {
walletAddressServiceRepository.validateMemo(network, memo)
}
}

View file

@ -22,6 +22,7 @@ dependencies {
implementation(deps.arrow.core)
implementation(deps.lifecycle.compose)
implementation(deps.jodatime)
implementation(deps.timber)
/** Compose */
implementation(deps.compose.accompanist.systemUiController)

View file

@ -1,7 +1,7 @@
package com.tangem.features.send.impl.presentation.state
import androidx.paging.PagingData
import com.tangem.blockchain.common.address.Address
import arrow.core.getOrElse
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.resourceReference
@ -10,33 +10,32 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.ValidateWalletMemoUseCase
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.amount.SendAmountStateConverter
import com.tangem.features.send.impl.presentation.state.fee.*
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldChangeConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldConverter
import com.tangem.features.send.impl.presentation.state.fee.calculateReceiveAmount
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientListConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientStateConverter
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.features.send.impl.presentation.viewmodel.isNotAddressInWallet
import com.tangem.features.send.impl.presentation.viewmodel.validateMemo
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import timber.log.Timber
import java.math.BigDecimal
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class SendStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val walletAddressesProvider: Provider<Set<Address>>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) {
@ -138,29 +137,32 @@ internal class SendStateFactory(
}
fun getOnRecipientAddressValidState(value: String, isValidAddress: Boolean): SendUiState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val state = currentStateProvider()
val recipientState = state.recipientState ?: return state
val isValidMemo = validateMemo(
memo = recipientState.addressTextField.value,
cryptoCurrency = cryptoCurrencyStatusProvider().currency,
)
val isAddressInWallet = isNotAddressInWallet(
address = value,
walletAddresses = walletAddressesProvider(),
)
val isValidMemo = validateWalletMemoUseCase(
memo = recipientState.memoTextField?.value.orEmpty(),
network = cryptoCurrencyStatus.currency.network,
).getOrElse {
Timber.e("Failed to validateWalletMemoUseCase: $it")
false
}
val isAddressInWallet = cryptoCurrencyStatus.value.networkAddress?.availableAddresses
?.any { it.value == value } ?: true
return state.copy(
recipientState = recipientState.copy(
isPrimaryButtonEnabled = isValidMemo && isValidAddress && isAddressInWallet,
isPrimaryButtonEnabled = isValidMemo && isValidAddress && !isAddressInWallet,
isValidating = false,
addressTextField = recipientState.addressTextField.copy(
error = when {
!isValidAddress || !isAddressInWallet -> resourceReference(
!isValidAddress || isAddressInWallet -> resourceReference(
R.string.send_recipient_address_error,
)
else -> null
},
isError = value.isNotEmpty() && !isValidAddress || !isAddressInWallet,
isError = value.isNotEmpty() && !isValidAddress || isAddressInWallet,
),
),
)
@ -185,23 +187,26 @@ internal class SendStateFactory(
}
fun getOnRecipientMemoValidState(value: String, isValidAddress: Boolean): SendUiState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val state = currentStateProvider()
val recipientState = state.recipientState ?: return state
val isValidMemo = validateMemo(
val isValidMemo = validateWalletMemoUseCase(
memo = value,
cryptoCurrency = cryptoCurrencyStatusProvider().currency,
)
val isAddressInWallet = isNotAddressInWallet(
walletAddresses = walletAddressesProvider(),
address = recipientState.addressTextField.value,
)
network = cryptoCurrencyStatus.currency.network,
).getOrElse {
Timber.e("Failed to validateWalletMemoUseCase: $it")
false
}
val isAddressInWallet = cryptoCurrencyStatus.value.networkAddress?.availableAddresses
?.any { it.value == value } ?: true
return state.copy(
recipientState = recipientState.copy(
isPrimaryButtonEnabled = isValidMemo && isValidAddress && isAddressInWallet,
isPrimaryButtonEnabled = isValidMemo && isValidAddress && !isAddressInWallet,
isValidating = false,
memoTextField = recipientState.memoTextField?.copy(
isError = value.isNotEmpty() || isValidMemo && isValidAddress && isAddressInWallet,
isError = value.isNotEmpty() && !isValidMemo,
),
),
)

View file

@ -85,12 +85,10 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier
)
}
val isButtonEnabled = remember {
isButtonEnabled(
currentState = currentState,
uiState = uiState,
)
}
val isButtonEnabled = isButtonEnabled(
currentState = currentState,
uiState = uiState,
)
AnimatedContent(
targetState = buttonTextId,

View file

@ -1,18 +0,0 @@
package com.tangem.features.send.impl.presentation.viewmodel
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.Address
import com.tangem.domain.tokens.model.CryptoCurrency
internal fun verifyAddress(address: String, cryptoCurrency: CryptoCurrency?): Boolean {
if (address.isEmpty()) return true
val blockchain = cryptoCurrency?.let {
Blockchain.fromId(cryptoCurrency.id.rawNetworkId)
} ?: return false
return blockchain.validateAddress(address)
}
internal fun isNotAddressInWallet(walletAddresses: Set<Address>, address: String): Boolean {
return walletAddresses.all { it.value != address }
}

View file

@ -15,7 +15,6 @@ import com.tangem.blockchain.blockchains.xrp.XrpAddressService
import com.tangem.blockchain.blockchains.xrp.XrpTransactionBuilder
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionExtras
import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -38,6 +37,7 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.domain.wallets.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.wallets.usecase.ValidateWalletMemoUseCase
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
@ -78,6 +78,7 @@ internal class SendViewModel @Inject constructor(
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
private val walletManagersFacade: WalletManagersFacade,
validateWalletMemoUseCase: ValidateWalletMemoUseCase,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver, SendClickIntents {
@ -97,10 +98,10 @@ internal class SendViewModel @Inject constructor(
clickIntents = this,
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
walletAddressesProvider = Provider { walletAddresses },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
validateWalletMemoUseCase = validateWalletMemoUseCase,
)
var uiState: SendUiState by mutableStateOf(stateFactory.getInitialState())
@ -109,16 +110,13 @@ internal class SendViewModel @Inject constructor(
private var userWallet: UserWallet by Delegates.notNull()
private var coinCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var walletAddresses = emptySet<Address>()
private var balanceJobHolder = JobHolder()
private var recipientsJobHolder = JobHolder()
private var walletAddressesJobHolder = JobHolder()
private var feeJobHolder = JobHolder()
private var addressValidationJobHolder = JobHolder()
override fun onCreate(owner: LifecycleOwner) {
getWalletAddresses()
subscribeOnCurrencyStatusUpdates(owner)
getFee()
}
@ -310,15 +308,6 @@ internal class SendViewModel @Inject constructor(
}.saveIn(feeJobHolder)
}
private fun getWalletAddresses() {
viewModelScope.launch(dispatchers.io) {
walletAddresses = walletManagersFacade.getAddresses(
userWalletId = userWalletId,
network = cryptoCurrency.network,
)
}.saveIn(walletAddressesJobHolder)
}
// region screen state navigation
override fun popBackStack() = stateRouter.popBackStack()
override fun onBackClick() = stateRouter.onBackClick()
@ -370,7 +359,7 @@ internal class SendViewModel @Inject constructor(
viewModelScope.launch(dispatchers.main) {
uiState = stateFactory.getOnRecipientAddressValidationStarted()
if (!checkIfXrpAddressValue(value)) {
val isValidAddress = validateAddress(value)
val isValidAddress = validateAddress(uiState.recipientState?.addressTextField?.value.orEmpty())
uiState = stateFactory.getOnRecipientMemoValidState(value, isValidAddress)
}
}.saveIn(addressValidationJobHolder)
@ -508,7 +497,7 @@ internal class SendViewModel @Inject constructor(
)
}
}
// endregion
// endregion
private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? {
val blockchain = Blockchain.fromId(networkId)