diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 61d03da6bf..9b3b5503e7 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -4,7 +4,6 @@ import com.tangem.data.wallets.hot.TangemHotWalletSigner import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier @@ -12,13 +11,11 @@ import com.tangem.domain.notifications.repository.PushNotificationsRepository import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.repository.CurrencyChecksRepository -import com.tangem.domain.transaction.FeeRepository -import com.tangem.domain.transaction.GaslessTransactionRepository -import com.tangem.domain.transaction.TransactionRepository -import com.tangem.domain.transaction.WalletAddressServiceRepository +import com.tangem.domain.transaction.* import com.tangem.domain.transaction.usecase.* import com.tangem.domain.transaction.usecase.gasless.* import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -175,8 +172,14 @@ internal object TransactionDomainModule { @Provides @Singleton - fun provideGetAllowanceUseCase(transactionRepository: TransactionRepository): GetAllowanceUseCase { - return GetAllowanceUseCase(transactionRepository) + fun provideGetAllowanceUseCase(allowanceRepository: AllowanceRepository): GetAllowanceUseCase { + return GetAllowanceUseCase(allowanceRepository) + } + + @Provides + @Singleton + fun provideGetAllowanceInfoUseCase(allowanceRepository: AllowanceRepository): GetAllowanceInfoUseCase { + return GetAllowanceInfoUseCase(allowanceRepository) } @Provides diff --git a/data/transaction/build.gradle.kts b/data/transaction/build.gradle.kts index a372c747a8..8aa184a6a6 100644 --- a/data/transaction/build.gradle.kts +++ b/data/transaction/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { /** Domain */ implementation(projects.libs.blockchainSdk) + implementation(projects.libs.crypto) implementation(projects.domain.legacy) implementation(projects.domain.walletManager) implementation(projects.domain.wallets.models) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt new file mode 100644 index 0000000000..29395fce20 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt @@ -0,0 +1,73 @@ +package com.tangem.data.transaction + +import com.tangem.blockchain.common.Approver +import com.tangem.blockchain.common.Token +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.AllowanceRepository +import com.tangem.domain.transaction.models.AllowanceInfo +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import java.math.BigDecimal + +internal class DefaultAllowanceRepository( + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : AllowanceRepository { + + override suspend fun getAllowanceInfo( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + spenderAddress: String, + requiredAmount: BigDecimal, + ): AllowanceInfo { + if (cryptoCurrency !is CryptoCurrency.Token) { + error("CryptoCurrency must be of type Token") + } + + val allowance = getAllowance( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + spenderAddress = spenderAddress, + ) + + return when { + allowance >= requiredAmount -> AllowanceInfo.Enough(allowance) + allowance > BigDecimal.ZERO && allowance < requiredAmount && + BlockchainUtils.isTetherInEthereum( + blockchainId = cryptoCurrency.network.rawId, + contractAddress = cryptoCurrency.contractAddress, + ) -> AllowanceInfo.ResetNeeded(allowance, requiredAmount) + else -> AllowanceInfo.NotEnough(allowance, requiredAmount) + } + } + + override suspend fun getAllowance( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + spenderAddress: String, + ): BigDecimal = withContext(dispatchers.io) { + if (cryptoCurrency !is CryptoCurrency.Token) { + error("CryptoCurrency must be of type Token") + } + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, cryptoCurrency.network) + val blockchain = cryptoCurrency.network.toBlockchain() + val allowanceResult = (walletManager as? Approver)?.getAllowance( + spenderAddress, + Token( + symbol = blockchain.currency, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + ) ?: error("Cannot cast to Approver") + + allowanceResult.fold( + onSuccess = { it }, + onFailure = { error(it) }, + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index ba4141909d..06da7b3fa9 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -25,7 +25,6 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.OperationType import com.tangem.datasource.api.tangemTech.models.TransactionEventBody import com.tangem.datasource.local.walletmanager.WalletManagersStore -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.TransactionRepository @@ -332,28 +331,6 @@ internal class DefaultTransactionRepository( } } - override suspend fun getAllowance( - userWalletId: UserWalletId, - cryptoCurrency: CryptoCurrency.Token, - spenderAddress: String, - ): BigDecimal { - val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, cryptoCurrency.network) - val blockchain = cryptoCurrency.network.toBlockchain() - val allowanceResult = (walletManager as? Approver)?.getAllowance( - spenderAddress, - Token( - symbol = blockchain.currency, - contractAddress = cryptoCurrency.contractAddress, - decimals = cryptoCurrency.decimals, - ), - ) ?: error("Cannot cast to Approver") - - return allowanceResult.fold( - onSuccess = { it }, - onFailure = { error(it) }, - ) - } - @Suppress("CyclomaticComplexMethod") private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? { val blockchain = Blockchain.fromId(networkId) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt index f3567dbef8..9f9f0ecbd8 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/di/TransactionDataModule.kt @@ -1,22 +1,14 @@ package com.tangem.data.transaction.di import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory -import com.tangem.data.transaction.DefaultFeeRepository -import com.tangem.data.transaction.DefaultGaslessTransactionRepository -import com.tangem.data.transaction.DefaultMemoValidatorFacade -import com.tangem.data.transaction.DefaultTransactionRepository -import com.tangem.data.transaction.DefaultWalletAddressServiceRepository +import com.tangem.data.transaction.* import com.tangem.data.transaction.error.DefaultFeeErrorResolver import com.tangem.blockchainsdk.BlockchainSDKFactory import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.demo.models.DemoConfig -import com.tangem.domain.transaction.FeeRepository -import com.tangem.domain.transaction.GaslessTransactionRepository -import com.tangem.domain.transaction.MemoValidatorFacade -import com.tangem.domain.transaction.TransactionRepository -import com.tangem.domain.transaction.WalletAddressServiceRepository +import com.tangem.domain.transaction.* import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -100,4 +92,16 @@ internal object TransactionDataModule { responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, ) } + + @Provides + @Singleton + fun provideAllowanceRepository( + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + ): AllowanceRepository { + return DefaultAllowanceRepository( + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt b/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt new file mode 100644 index 0000000000..0211c42b33 --- /dev/null +++ b/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt @@ -0,0 +1,305 @@ +package com.tangem.data.transaction + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Approver +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.WalletManager +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.models.AllowanceInfo +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.math.BigDecimal + +class DefaultAllowanceRepositoryTest { + + private val userWalletId = UserWalletId(stringValue = "1234567890ABCDEF") + private val spenderAddress = "0xSpender" + + private val approverWalletManager: WalletManager = + mockk(moreInterfaces = arrayOf(Approver::class)) + + private val walletManagersFacade: WalletManagersFacade = mockk { + coEvery { getOrCreateWalletManager(userWalletId, any()) } returns approverWalletManager + } + + private val dispatchers = TestingCoroutineDispatcherProvider() + private lateinit var repository: DefaultAllowanceRepository + + @BeforeEach + fun setup() { + repository = DefaultAllowanceRepository( + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + ) + } + + // region getAllowance + + @Nested + inner class GetAllowanceTests { + + @Test + fun `throws when cryptoCurrency is Coin`() = runTest { + val coin = buildCoin() + + assertThrows { + repository.getAllowance(userWalletId, coin, spenderAddress) + } + } + + @Test + fun `throws when walletManager is null`() = runTest { + val token = buildToken() + coEvery { + walletManagersFacade.getOrCreateWalletManager(userWalletId, token.network) + } returns null + + assertThrows { + repository.getAllowance(userWalletId, token, spenderAddress) + } + } + + @Test + fun `throws when walletManager is not Approver`() = runTest { + val token = buildToken() + val nonApproverWalletManager: WalletManager = mockk() + coEvery { + walletManagersFacade.getOrCreateWalletManager(userWalletId, token.network) + } returns nonApproverWalletManager + + assertThrows { + repository.getAllowance(userWalletId, token, spenderAddress) + } + } + + @Test + fun `returns allowance on success`() = runTest { + val token = buildToken() + val expected = BigDecimal("100") + + coEvery { + (approverWalletManager as Approver).getAllowance(spenderAddress, any()) + } returns Result.success(expected) + + val result = repository.getAllowance(userWalletId, token, spenderAddress) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `throws when approver returns failure`() = runTest { + val token = buildToken() + coEvery { + (approverWalletManager as Approver).getAllowance(spenderAddress, any()) + } returns Result.failure(RuntimeException("rpc error")) + + assertThrows { + repository.getAllowance(userWalletId, token, spenderAddress) + } + } + } + + // endregion + + // region getAllowanceInfo + + @Nested + inner class GetAllowanceInfoTests { + + @Test + fun `throws when cryptoCurrency is Coin`() = runTest { + val coin = buildCoin() + + assertThrows { + repository.getAllowanceInfo(userWalletId, coin, spenderAddress, BigDecimal.ONE) + } + } + + @Test + fun `returns Enough when allowance equals required amount`() = runTest { + val token = buildToken(rawNetworkId = "polygon", rawCurrencyId = "usd-coin") + val amount = BigDecimal("100") + + coEvery { + (approverWalletManager as Approver).getAllowance(spenderAddress, any()) + } returns Result.success(amount) + + val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, amount) + + assertThat(result).isInstanceOf(AllowanceInfo.Enough::class.java) + assertThat((result as AllowanceInfo.Enough).allowance).isEqualTo(amount) + } + + @Test + fun `returns Enough when allowance exceeds required amount`() = runTest { + val token = buildToken(rawNetworkId = "polygon", rawCurrencyId = "usd-coin") + + coEvery { + (approverWalletManager as Approver).getAllowance(spenderAddress, any()) + } returns Result.success(BigDecimal("200")) + + val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("100")) + + assertThat(result).isInstanceOf(AllowanceInfo.Enough::class.java) + assertThat((result as AllowanceInfo.Enough).allowance).isEqualTo(BigDecimal("200")) + } + + @Test + fun `returns NotEnough when allowance is zero`() = runTest { + val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "tether") + + coEvery { + (approverWalletManager as Approver).getAllowance(spenderAddress, any()) + } returns Result.success(BigDecimal.ZERO) + + val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("50")) + + assertThat(result).isInstanceOf(AllowanceInfo.NotEnough::class.java) + result as AllowanceInfo.NotEnough + assertThat(result.allowance).isEqualTo(BigDecimal.ZERO) + assertThat(result.requiredAmount).isEqualTo(BigDecimal("50")) + } + + @Test + fun `returns NotEnough when partial allowance for non-tether token`() = runTest { + val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "usd-coin") + + coEvery { + (approverWalletManager as Approver).getAllowance(spenderAddress, any()) + } returns Result.success(BigDecimal("30")) + + val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("100")) + + assertThat(result).isInstanceOf(AllowanceInfo.NotEnough::class.java) + result as AllowanceInfo.NotEnough + assertThat(result.allowance).isEqualTo(BigDecimal("30")) + assertThat(result.requiredAmount).isEqualTo(BigDecimal("100")) + } + + @Test + fun `returns NotEnough when partial allowance for tether on non-ethereum network`() = runTest { + val token = buildToken(rawNetworkId = "polygon", rawCurrencyId = "tether") + + coEvery { + (approverWalletManager as Approver).getAllowance(spenderAddress, any()) + } returns Result.success(BigDecimal("30")) + + val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("100")) + + assertThat(result).isInstanceOf(AllowanceInfo.NotEnough::class.java) + } + + @Test + fun `returns ResetNeeded when partial allowance for tether on ethereum`() = runTest { + val token = buildToken(rawNetworkId = "ETH", rawCurrencyId = "tether") + + coEvery { + (approverWalletManager as Approver).getAllowance(spenderAddress, any()) + } returns Result.success(BigDecimal("30")) + + val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("100")) + + assertThat(result).isInstanceOf(AllowanceInfo.ResetNeeded::class.java) + result as AllowanceInfo.ResetNeeded + assertThat(result.allowance).isEqualTo(BigDecimal("30")) + assertThat(result.requiredAmount).isEqualTo(BigDecimal("100")) + } + + @Test + fun `returns ResetNeeded when partial allowance for tether on ethereum testnet`() = runTest { + val token = buildToken(rawNetworkId = "ETH/test", rawCurrencyId = "tether") + + coEvery { + (approverWalletManager as Approver).getAllowance(spenderAddress, any()) + } returns Result.success(BigDecimal("10")) + + val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("50")) + + assertThat(result).isInstanceOf(AllowanceInfo.ResetNeeded::class.java) + } + + @Test + fun `returns Enough for tether on ethereum when allowance is sufficient`() = runTest { + val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "tether") + + coEvery { + (approverWalletManager as Approver).getAllowance(spenderAddress, any()) + } returns Result.success(BigDecimal("100")) + + val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("100")) + + assertThat(result).isInstanceOf(AllowanceInfo.Enough::class.java) + } + } + + // endregion + + // region Helpers + + private fun buildNetwork(rawNetworkId: String): Network { + val derivationPath = Network.DerivationPath.None + return Network( + id = Network.ID(Network.RawID(rawNetworkId), derivationPath), + backendId = rawNetworkId, + name = rawNetworkId.replaceFirstChar { it.uppercase() }, + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = rawNetworkId.contains("test"), + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = false, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + private fun buildToken( + rawNetworkId: String = "ETH", + rawCurrencyId: String = "tether", + contractAddress: String = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ): CryptoCurrency.Token { + val network = buildNetwork(rawNetworkId) + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawCurrencyId), + ), + network = network, + name = "Token", + symbol = "TKN", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } + + private fun buildCoin(rawNetworkId: String = "ethereum"): CryptoCurrency.Coin { + val network = buildNetwork(rawNetworkId) + return CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network, + name = "Ethereum", + symbol = "ETH", + decimals = 18, + iconUrl = null, + isCustom = false, + ) + } + + // endregion +} diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/AllowanceRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/AllowanceRepository.kt new file mode 100644 index 0000000000..8f7075c563 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/AllowanceRepository.kt @@ -0,0 +1,46 @@ +package com.tangem.domain.transaction + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.models.AllowanceInfo +import java.math.BigDecimal + +/** + * Repository interface for managing token allowances in the context of blockchain transactions. + */ +interface AllowanceRepository { + + /** + * Retrieves the allowance information for a specific spender and required amount. + * + * @param userWalletId The ID of the user's wallet. + * @param cryptoCurrency The cryptocurrency for which the allowance is being checked (must be a token). + * @param spenderAddress The address of the spender for whom the allowance is being checked. + * @param requiredAmount The amount that is required for the transaction. + * + * @return An [AllowanceInfo] object that indicates whether the current allowance. + * @throws IllegalStateException if the provided [cryptoCurrency] is not a token. + */ + suspend fun getAllowanceInfo( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + spenderAddress: String, + requiredAmount: BigDecimal, + ): AllowanceInfo + + /** + * Retrieves the current allowance for a specific spender. + * + * @param userWalletId The ID of the user's wallet. + * @param cryptoCurrency The cryptocurrency for which the allowance is being checked (must be a token). + * @param spenderAddress The address of the spender for whom the allowance is being checked. + * + * @return The current allowance as a [BigDecimal]. + * @throws IllegalStateException if the provided [cryptoCurrency] is not a token. + */ + suspend fun getAllowance( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + spenderAddress: String, + ): BigDecimal +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt index 74958179f4..e26582e022 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt @@ -6,11 +6,9 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionSendResult import com.tangem.blockchain.common.transaction.TransactionsSendResult import com.tangem.blockchain.nft.models.NFTAsset -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.models.EventTransactionTypeDto -import java.math.BigDecimal import java.math.BigInteger interface TransactionRepository { @@ -91,12 +89,6 @@ interface TransactionRepository { gasLimit: BigInteger?, ): TransactionExtras - suspend fun getAllowance( - userWalletId: UserWalletId, - cryptoCurrency: CryptoCurrency.Token, - spenderAddress: String, - ): BigDecimal - suspend fun prepareForSend( transactionData: TransactionData, signer: TransactionSigner, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/AllowanceInfo.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/AllowanceInfo.kt new file mode 100644 index 0000000000..43ec01bf10 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/AllowanceInfo.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.transaction.models + +import java.math.BigDecimal + +/** + * Model that represents the allowance information for a specific spender and required amount. + */ +sealed class AllowanceInfo { + + /** + * Represents a state where the current allowance is sufficient to cover the required amount. + */ + data class Enough(val allowance: BigDecimal) : AllowanceInfo() + + /** + * Represents a state where the current allowance is insufficient to cover the required amount. + */ + data class NotEnough(val allowance: BigDecimal, val requiredAmount: BigDecimal) : AllowanceInfo() + + /** + * Represents a state where the current allowance is insufficient, + * but it must be reset to cover the required amount (specific to certain tokens like Tether in Ethereum). + */ + data class ResetNeeded(val allowance: BigDecimal, val requiredAmount: BigDecimal) : AllowanceInfo() +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceInfoUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceInfoUseCase.kt new file mode 100644 index 0000000000..93162b472a --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceInfoUseCase.kt @@ -0,0 +1,32 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.AllowanceRepository +import com.tangem.domain.transaction.models.AllowanceInfo +import java.math.BigDecimal + +/** + * Use case for retrieving the allowance information for a specific spender and required amount. + */ +class GetAllowanceInfoUseCase( + private val allowanceRepository: AllowanceRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + spenderAddress: String, + requiredAmount: BigDecimal, + ): Either { + return Either.catch { + allowanceRepository.getAllowanceInfo( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + spenderAddress = spenderAddress, + requiredAmount = requiredAmount, + ) + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceUseCase.kt index 2f09a5bc8d..ccc3a90c5a 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceUseCase.kt @@ -2,12 +2,15 @@ package com.tangem.domain.transaction.usecase import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.AllowanceRepository import java.math.BigDecimal +/** + * Use case for retrieving the current allowance for a specific spender. + */ class GetAllowanceUseCase( - private val transactionRepository: TransactionRepository, + private val allowanceRepository: AllowanceRepository, ) { suspend operator fun invoke( @@ -16,9 +19,9 @@ class GetAllowanceUseCase( spenderAddress: String, ): Either { return Either.catch { - transactionRepository.getAllowance( + allowanceRepository.getAllowance( userWalletId = userWalletId, - cryptoCurrency = cryptoCurrency as CryptoCurrency.Token, + cryptoCurrency = cryptoCurrency, spenderAddress = spenderAddress, ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index 90eea6d0fa..6123e22384 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -34,7 +34,8 @@ import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection import com.tangem.domain.swap.usecase.GetSwapQuoteUseCase import com.tangem.domain.swap.usecase.SelectInitialPairUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase -import com.tangem.domain.transaction.usecase.GetAllowanceUseCase +import com.tangem.domain.transaction.models.AllowanceInfo +import com.tangem.domain.transaction.usecase.GetAllowanceInfoUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.send.v2.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger @@ -81,7 +82,7 @@ internal class SwapAmountModel @Inject constructor( private val selectInitialPairUseCase: SelectInitialPairUseCase, private val getSwapQuoteUseCase: GetSwapQuoteUseCase, private val swapChooseTokenNetworkListener: SwapChooseTokenNetworkListener, - private val getAllowanceUseCase: GetAllowanceUseCase, + private val getAllowanceInfoUseCase: GetAllowanceInfoUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserCountryUseCase: GetUserCountryUseCase, private val swapBestRateAnimationStore: SwapBestRateAnimationStore, @@ -805,18 +806,15 @@ internal class SwapAmountModel @Inject constructor( } private suspend fun checkAllowance(state: SwapAmountUM.Content, quote: SwapQuoteModel): Boolean { - val allowanceContract = quote.allowanceContract - val allowance = if (allowanceContract != null) { - getAllowanceUseCase( - userWalletId = userWallet.walletId, - cryptoCurrency = state.primaryCryptoCurrencyStatus.currency, - spenderAddress = allowanceContract, - ).getOrNull() - } else { - BigDecimal.ZERO - } + val allowanceContract = quote.allowanceContract ?: return false + val allowance = getAllowanceInfoUseCase( + userWalletId = userWallet.walletId, + cryptoCurrency = state.primaryCryptoCurrencyStatus.currency, + spenderAddress = allowanceContract, + requiredAmount = state.primaryCryptoCurrencyStatus.value.amount.orZero(), + ).getOrNull() - return allowance.orZero() < state.primaryCryptoCurrencyStatus.value.amount.orZero() + return allowance !is AllowanceInfo.Enough } private fun saveResult() { diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index deff8ff1f2..e27cb8705f 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -6,10 +6,6 @@ import arrow.core.raise.catch import arrow.core.raise.either import arrow.core.right import com.squareup.moshi.Moshi -import com.tangem.blockchain.common.Approver -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.Token -import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow @@ -27,7 +23,6 @@ import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.feature.swap.converters.* import com.tangem.feature.swap.domain.api.SwapRepository @@ -36,12 +31,11 @@ import com.tangem.feature.swap.domain.models.ExpressException import com.tangem.feature.swap.domain.models.createFromAmountWithOffset import com.tangem.feature.swap.domain.models.domain.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.async import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext -import com.tangem.utils.logging.TangemLogger import java.io.IOException -import java.math.BigDecimal import java.util.UUID import com.tangem.datasource.api.express.models.request.LeastTokenInfo as NetworkLeastTokenInfo @@ -410,36 +404,6 @@ internal class DefaultSwapRepository( } } - override suspend fun getAllowance( - userWalletId: UserWalletId, - networkId: String, - derivationPath: String?, - tokenDecimalCount: Int, - tokenAddress: String, - spenderAddress: String, - ): BigDecimal { - val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } - val walletManager = walletManagersFacade.getOrCreateWalletManager( - userWalletId = userWalletId, - blockchain = blockchain, - derivationPath = derivationPath, - ) - - val result = (walletManager as? Approver)?.getAllowance( - spenderAddress, - Token( - symbol = blockchain.currency, - contractAddress = tokenAddress, - decimals = tokenDecimalCount, - ), - ) ?: error("Cannot cast to Approver") - - return result.fold( - onSuccess = { it }, - onFailure = { error(it) }, - ) - } - private fun getDataError(ex: Exception): ExpressDataError { return if (ex is ApiResponseError.HttpException) { errorsDataConverter.convert(ex.errorBody.orEmpty()) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 405c0aa43b..7987443ea3 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -51,6 +51,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.AllowanceInfo import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.* import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase @@ -112,6 +113,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val walletManagersFacade: WalletManagersFacade, + private val getAllowanceInfoUseCase: GetAllowanceInfoUseCase, @Assisted private val userWalletId: UserWalletId, ) : SwapInteractor { @@ -431,12 +433,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( val isAllowedToSpend = maybeQuotes.fold( ifRight = { quotes -> quotes.allowanceContract?.let { allowanceContract -> - isAllowedToSpend( - networkId = networkId, - fromToken = fromToken.currency, - amount = amount, + getAllowanceInfoUseCase( + userWalletId = userWalletId, + cryptoCurrency = fromToken.currency, spenderAddress = allowanceContract, - ) + requiredAmount = amount.value, + ).getOrNull() is AllowanceInfo.Enough } != false }, ifLeft = { false }, @@ -1268,25 +1270,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( ?: error("Unable to create network coin with ID: ${network.id}") } - private suspend fun isAllowedToSpend( - networkId: String, - fromToken: CryptoCurrency, - amount: SwapAmount, - spenderAddress: String, - ): Boolean { - if (fromToken is CryptoCurrency.Coin) return true - - val allowance = repository.getAllowance( - userWalletId = userWallet.walletId, - networkId = networkId, - derivationPath = fromToken.network.derivationPath.value, - tokenDecimalCount = fromToken.decimals, - tokenAddress = getTokenAddress(fromToken), - spenderAddress = spenderAddress, - ) - return allowance >= amount.value - } - private suspend fun createEmptyAmountState(): SwapState { val appCurrency = getSelectedAppCurrencyUseCase.unwrap() return SwapState.EmptyAmountState( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt index c9ba429d75..c197aac5a9 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt @@ -4,10 +4,8 @@ import arrow.core.Either import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.* -import java.math.BigDecimal interface SwapRepository { @@ -41,17 +39,6 @@ interface SwapRepository { rateType: RateType, ): Either - @Suppress("LongParameterList") - @Throws(IllegalStateException::class) - suspend fun getAllowance( - userWalletId: UserWalletId, - networkId: String, - derivationPath: String?, - tokenDecimalCount: Int, - tokenAddress: String, - spenderAddress: String, - ): BigDecimal - @Suppress("LongParameterList") suspend fun getExchangeData( userWallet: UserWallet, diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index 1be48e89c6..b3108e4f2e 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -23,6 +23,7 @@ object BlockchainUtils { private const val XRP_X_ADDRESS = 'X' private const val TERRA_CLASSIC_USD_COIN_ID = "terrausd" private const val TERRA_LUNA_CLASSIC_COIN_ID = "terra-luna" + private const val TETHER_CONTRACT_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7" const val SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES = 930 /** Decodes XRP Blockchain address */ @@ -217,4 +218,13 @@ object BlockchainUtils { coinId == TERRA_CLASSIC_USD_COIN_ID || coinId == TERRA_LUNA_CLASSIC_COIN_ID } + + /** + * Checks if the given coin is Tether on Ethereum network, which may require special handling in some cases. + */ + fun isTetherInEthereum(blockchainId: String, contractAddress: String): Boolean { + val blockchain = Blockchain.fromId(blockchainId) + return (blockchain == Blockchain.Ethereum || blockchain == Blockchain.EthereumTestnet) && + contractAddress.equals(TETHER_CONTRACT_ADDRESS, ignoreCase = true) + } } \ No newline at end of file