From 25664700f6367846ebd2df291ac9583498bdef7b Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Sep 2025 19:30:17 +0500 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + .../tap/di/domain/YieldSupplyDomainModule.kt | 50 +++ data/transaction/build.gradle.kts | 17 +- .../data/transaction/DefaultFeeRepository.kt | 46 ++- .../transaction/di/TransactionDataModule.kt | 16 +- .../error/DefaultFeeErrorResolver.kt | 22 ++ data/yield-supply/build.gradle.kts | 48 +++ ...DefaultYieldSupplyTransactionRepository.kt | 371 ++++++++++++++++++ .../yield/supply/di/YieldSupplyDataModule.kt | 28 ++ ...ultYieldSupplyTransactionRepositoryTest.kt | 288 ++++++++++++++ .../tangem/domain/utils/BigDecimalUtils.kt | 32 +- .../domain/transaction/FeeRepository.kt | 11 + .../transaction/error/FeeErrorResolver.kt | 6 + domain/yield-supply/build.gradle.kts | 35 ++ .../YieldSupplyTransactionRepository.kt | 23 ++ .../YieldSupplyEstimateEnterFeeUseCase.kt | 58 +++ .../usecase/YieldSupplyStartEarningUseCase.kt | 22 ++ .../usecase/YieldSupplyStopEarningUseCase.kt | 29 ++ .../YieldSupplyEstimateEnterFeeUseCaseTest.kt | 172 ++++++++ settings.gradle.kts | 2 + 20 files changed, 1261 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt create mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt create mode 100644 data/yield-supply/build.gradle.kts create mode 100644 data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt create mode 100644 data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt create mode 100644 domain/yield-supply/build.gradle.kts create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt create mode 100644 domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 523778d665..cfb020cedd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,6 +153,7 @@ dependencies { implementation(projects.domain.swap) implementation(projects.domain.walletManager) implementation(projects.domain.walletManager.models) + implementation(projects.domain.yieldSupply) implementation(projects.common) implementation(projects.common.routing) @@ -201,6 +202,7 @@ dependencies { implementation(projects.data.notifications) implementation(projects.data.swap) implementation(projects.data.walletManager) + implementation(projects.data.yieldSupply) /** Features */ implementation(projects.features.referral.impl) diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt new file mode 100644 index 0000000000..1b342eb608 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -0,0 +1,50 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object YieldSupplyDomainModule { + + @Provides + @Singleton + fun provideYieldSupplyStartEarningUseCase( + yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, + ): YieldSupplyStartEarningUseCase { + return YieldSupplyStartEarningUseCase( + yieldSupplyTransactionRepository = yieldSupplyTransactionRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyStopEarningUseCase( + yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, + ): YieldSupplyStopEarningUseCase { + return YieldSupplyStopEarningUseCase( + yieldSupplyTransactionRepository = yieldSupplyTransactionRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyEstimateEnterFeeUseCase( + feeRepository: FeeRepository, + feeErrorResolver: FeeErrorResolver, + ): YieldSupplyEstimateEnterFeeUseCase { + return YieldSupplyEstimateEnterFeeUseCase( + feeRepository = feeRepository, + feeErrorResolver = feeErrorResolver, + ) + } +} \ No newline at end of file diff --git a/data/transaction/build.gradle.kts b/data/transaction/build.gradle.kts index 47cc934a90..fa74ec6c43 100644 --- a/data/transaction/build.gradle.kts +++ b/data/transaction/build.gradle.kts @@ -10,6 +10,10 @@ android { namespace = "com.tangem.data.transaction" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Tangem SDKs */ @@ -21,13 +25,14 @@ dependencies { implementation(projects.core.utils) /** Domain */ - implementation(projects.domain.transaction) + implementation(projects.libs.blockchainSdk) implementation(projects.domain.legacy) implementation(projects.domain.walletManager) - implementation(projects.libs.blockchainSdk) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens.models) implementation(projects.domain.transaction.models) + implementation(projects.domain.transaction) + implementation(projects.domain.demo) /** DI */ implementation(deps.hilt.android) @@ -35,4 +40,12 @@ dependencies { /** Other */ implementation(deps.timber) + + /** tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt index b018bc23c3..49b6e3c486 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultFeeRepository.kt @@ -1,13 +1,57 @@ package com.tangem.data.transaction import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.demo.DemoTransactionSender +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.walletmanager.WalletManagersFacade -internal class DefaultFeeRepository : FeeRepository { +internal class DefaultFeeRepository( + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, +) : FeeRepository { override fun isFeeApproximate(networkId: Network.ID, amountType: AmountType): Boolean { return networkId.toBlockchain().isFeeApproximate(amountType) } + + override suspend fun calculateFee( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + transactionData: TransactionData, + ): TransactionFee { + val transactionSender = if (userWallet is UserWallet.Cold && + demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId) + ) { + demoTransactionSender(userWallet, cryptoCurrency) + } else { + walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) ?: error("WalletManager is null") + } + + return when (val result = transactionSender.getFee(transactionData)) { + is Result.Success -> result.data + is Result.Failure -> throw result.error + } + } + + private suspend fun demoTransactionSender( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + ): DemoTransactionSender { + return DemoTransactionSender( + walletManagersFacade + .getOrCreateWalletManager(userWallet.walletId, cryptoCurrency.network) + ?: error("WalletManager is null"), + ) + } } \ No newline at end of file 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 6184d3fa48..267e449c57 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 @@ -3,10 +3,13 @@ package com.tangem.data.transaction.di import com.tangem.data.transaction.DefaultFeeRepository import com.tangem.data.transaction.DefaultTransactionRepository import com.tangem.data.transaction.DefaultWalletAddressServiceRepository +import com.tangem.data.transaction.error.DefaultFeeErrorResolver 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.TransactionRepository import com.tangem.domain.transaction.WalletAddressServiceRepository +import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -35,8 +38,11 @@ internal object TransactionDataModule { @Provides @Singleton - fun providesFeeRepository(): FeeRepository { - return DefaultFeeRepository() + fun providesFeeRepository(walletManagersFacade: WalletManagersFacade): FeeRepository { + return DefaultFeeRepository( + walletManagersFacade, + demoConfig = DemoConfig(), + ) } @Provides @@ -50,4 +56,10 @@ internal object TransactionDataModule { dispatchers = coroutineDispatcherProvider, ) } + + @Provides + @Singleton + fun providerFeeErrorResolver(): FeeErrorResolver { + return DefaultFeeErrorResolver() + } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt b/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt new file mode 100644 index 0000000000..b1469917d7 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/error/DefaultFeeErrorResolver.kt @@ -0,0 +1,22 @@ +package com.tangem.data.transaction.error + +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.transaction.error.GetFeeError + +internal class DefaultFeeErrorResolver : FeeErrorResolver { + override fun resolve(throwable: Throwable): GetFeeError { + return when (throwable) { + is BlockchainSdkError.Tron.AccountActivationError -> { + GetFeeError.BlockchainErrors.TronActivationError + } + is BlockchainSdkError.Kaspa.ZeroUtxoError -> { + GetFeeError.BlockchainErrors.KaspaZeroUtxo + } + is BlockchainSdkError.Sui.OneSuiRequired -> { + GetFeeError.BlockchainErrors.SuiOneCoinRequired + } + else -> GetFeeError.DataError(throwable) + } + } +} \ No newline at end of file diff --git a/data/yield-supply/build.gradle.kts b/data/yield-supply/build.gradle.kts new file mode 100644 index 0000000000..b0cc1f2844 --- /dev/null +++ b/data/yield-supply/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.data.yield.supply" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + + /** Tangem SDKs */ + implementation(tangemDeps.blockchain) + + /** Core */ + implementation(projects.core.datasource) + implementation(projects.core.utils) + + /** Domain */ + implementation(projects.domain.yieldSupply) + implementation(projects.domain.walletManager) + implementation(projects.domain.legacy) + + implementation(projects.libs.blockchainSdk) + + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /** Other */ + implementation(deps.timber) + + /** tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt new file mode 100644 index 0000000000..86c802d7db --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt @@ -0,0 +1,371 @@ +package com.tangem.data.yield.supply + +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.blockchains.ethereum.tokenmethods.ApprovalERC20TokenCallData +import com.tangem.blockchain.blockchains.tron.TronTransactionExtras +import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.math.BigDecimal + +@Suppress("LargeClass") +internal class DefaultYieldSupplyTransactionRepository( + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : YieldSupplyTransactionRepository { + + override suspend fun createEnterTransactions( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): List { + val cryptoCurrency = cryptoCurrencyStatus.currency + + require(cryptoCurrency is CryptoCurrency.Token) + + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + + val existingYieldContractAddress = getYieldContractAddress( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) + + val calculatedYieldContractAddress = calculateYieldContractAddress( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) ?: error("Calculated yield contract address is null") + + val yieldTokenStatus = cryptoCurrencyStatus.value.yieldSupplyStatus ?: getYieldTokenStatus( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + ) + + return buildEnterTransactions( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + existingYieldContractAddress = existingYieldContractAddress, + calculatedYieldContractAddress = calculatedYieldContractAddress, + yieldTokenStatus = yieldTokenStatus, + ) + } + + override suspend fun createExitTransaction( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + yieldSupplyStatus: YieldSupplyStatus, + fee: Fee?, + ): TransactionData.Uncompiled = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + + val callData = YieldSupplyContractCallDataProviderFactory.getExitCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + ) + + createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = walletManager.getYieldContract(), + yieldSupplyStatus = yieldSupplyStatus, + fee = fee, + ) + } + + private fun buildEnterTransactions( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + existingYieldContractAddress: String?, + calculatedYieldContractAddress: String, + yieldTokenStatus: YieldSupplyStatus?, + ): MutableList { + val enterTransactions = mutableListOf() + + when { + existingYieldContractAddress == null || existingYieldContractAddress == EthereumUtils.ZERO_ADDRESS -> { + enterTransactions.add( + createDeployTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + ), + ) + } + yieldTokenStatus == null -> error("Yield token status is null") + !yieldTokenStatus.isInitialized -> enterTransactions.add( + createInitTokenTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + yieldContractAddress = calculatedYieldContractAddress, + ), + ) + !yieldTokenStatus.isActive -> enterTransactions.add( + createReactivateTokenTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + yieldContractAddress = calculatedYieldContractAddress, + ), + ) + else -> Unit + } + + if (yieldTokenStatus?.isAllowedToSpend == false) { + enterTransactions.add( + createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = ApprovalERC20TokenCallData( + spenderAddress = calculatedYieldContractAddress, + amount = null, + ), + destinationAddress = cryptoCurrency.contractAddress, + yieldSupplyStatus = yieldTokenStatus, + fee = null, + ), + ) + } + + enterTransactions.add( + createEnterTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + yieldContractAddress = calculatedYieldContractAddress, + ), + ) + + return enterTransactions + } + + private suspend fun calculateYieldContractAddress( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): String? = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + runCatching { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + walletManager.calculateYieldContract() + }.onFailure(Timber::e) + .getOrNull() + } + + private suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? = + withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + runCatching { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + walletManager.getYieldContract() + }.onFailure(Timber::e) + .getOrNull() + } + + private suspend fun getYieldTokenStatus( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency, + ): YieldSupplyStatus? = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + runCatching { + val sdkSupplyStatus = walletManager.getYieldSupplyStatus(cryptoCurrency.contractAddress) + val isAllowedToSpend = walletManager.isAllowedToSpend(cryptoCurrency.contractAddress) + + YieldSupplyStatus( + isActive = sdkSupplyStatus?.isActive == true, + isInitialized = sdkSupplyStatus?.isInitialized == true, + isAllowedToSpend = isAllowedToSpend, + ) + }.onFailure(Timber::e).getOrNull() + } + + private fun createDeployTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getDeployCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + walletAddress = walletManager.wallet.address, + maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency), + ) + + val factoryContractAddress = walletManager.getYieldSupplyContractAddresses()?.factoryContractAddress + ?: error("Factory contract address is null") + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = factoryContractAddress, + yieldSupplyStatus = null, + fee = null, + ) + } + + private fun createInitTokenTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + yieldContractAddress: String, + yieldSupplyStatus: YieldSupplyStatus, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getInitTokenCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency), + ) + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = yieldContractAddress, + yieldSupplyStatus = yieldSupplyStatus, + fee = null, + ) + } + + private fun createReactivateTokenTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + yieldContractAddress: String, + yieldSupplyStatus: YieldSupplyStatus, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency), + ) + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = yieldContractAddress, + yieldSupplyStatus = yieldSupplyStatus, + fee = null, + ) + } + + private fun createEnterTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency.Token, + yieldSupplyStatus: YieldSupplyStatus?, + yieldContractAddress: String, + ): TransactionData.Uncompiled { + val callData = YieldSupplyContractCallDataProviderFactory.getEnterCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + ) + + return createTransaction( + walletManager = walletManager, + cryptoCurrency = cryptoCurrency, + callData = callData, + destinationAddress = yieldContractAddress, + yieldSupplyStatus = yieldSupplyStatus, + fee = null, + ) + } + + @Suppress("LongParameterList") + private fun createTransaction( + walletManager: WalletManager, + cryptoCurrency: CryptoCurrency, + callData: SmartContractCallData, + destinationAddress: String, + yieldSupplyStatus: YieldSupplyStatus?, + fee: Fee?, + ): TransactionData.Uncompiled { + requireNotNull(cryptoCurrency as? CryptoCurrency.Token) + val blockchain = cryptoCurrency.network.id.toBlockchain() + + val extras = createTransactionDataExtras( + callData = callData, + blockchain = blockchain, + ) + + val amount = getYieldSupplyAmount(cryptoCurrency, yieldSupplyStatus) + + return if (fee != null) { + walletManager.createTransaction( + amount = amount, + fee = fee, + destination = destinationAddress, + ).copy( + extras = extras, + ) + } else { + TransactionData.Uncompiled( + amount = amount, + sourceAddress = walletManager.wallet.address, + destinationAddress = destinationAddress, + extras = extras, + fee = null, + ) + } + } + + private fun createTransactionDataExtras( + callData: SmartContractCallData, + blockchain: Blockchain, + ): TransactionExtras { + return when { + blockchain.isEvm() -> { + EthereumTransactionExtras( + callData = callData, + gasLimit = null, + nonce = null, + ) + } + blockchain == Blockchain.Tron -> { + TronTransactionExtras( + callData = callData, + ) + } + else -> error("Data extras not supported for $blockchain") + } + } + + private fun getYieldSupplyAmount(cryptoCurrency: CryptoCurrency.Token, yieldSupplyStatus: YieldSupplyStatus?) = + BigDecimal.ZERO.convertToSdkAmount( + cryptoCurrency = cryptoCurrency, + amountType = AmountType.TokenYieldSupply( + token = Token( + symbol = cryptoCurrency.symbol, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + isActive = yieldSupplyStatus?.isActive ?: false, + isInitialized = yieldSupplyStatus?.isInitialized ?: false, + isAllowedToSpend = yieldSupplyStatus?.isAllowedToSpend ?: false, + ), + ) + + private companion object { + val MAX_NETWORK_FEE: BigDecimal = BigDecimal.TEN // TODO for TESTNET only + } +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt new file mode 100644 index 0000000000..2b2f45f06c --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -0,0 +1,28 @@ +package com.tangem.data.yield.supply.di + +import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object YieldSupplyDataModule { + + @Provides + @Singleton + fun providerYieldSupplyTransactionRepository( + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + ): YieldSupplyTransactionRepository { + return DefaultYieldSupplyTransactionRepository( + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + ) + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt new file mode 100644 index 0000000000..069e3630f8 --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt @@ -0,0 +1,288 @@ +package com.tangem.data.yield.supply + +import com.google.common.truth.Truth +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory +import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import com.tangem.blockchain.yieldsupply.providers.YieldSupplyStatus as SDKYieldSupplyStatus +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultYieldSupplyTransactionRepositoryTest { + + private val networkId = Network.ID(value = "ETH/test", derivationPath = Network.DerivationPath.None) + private val mockedContractAddress = "0x000000000000000000000000000000000000" + private val yieldContractAddress = "0x1234" + + private val userWalletId = mockk() + private val cryptoCurrency = mockk(relaxed = true) { + every { network.id } returns networkId + every { contractAddress } returns mockedContractAddress + } + private val cryptoCurrencyStatus = mockk(relaxed = true) { + every { currency } returns cryptoCurrency + every { value.yieldSupplyStatus } returns null + } + + private val walletManager = mockk(relaxed = true) { + every { wallet } returns mockk(relaxed = true) + every { getYieldSupplyContractAddresses() } returns mockk(relaxed = true) { + every { factoryContractAddress } returns "factory" + } + } + private val walletManagersFacade: WalletManagersFacade = mockk { + coEvery { getOrCreateWalletManager(any(), any(), any()) } returns walletManager + } + private lateinit var repository: DefaultYieldSupplyTransactionRepository + + @BeforeEach + fun setUp() { + repository = spyk( + objToCopy = DefaultYieldSupplyTransactionRepository( + walletManagersFacade = walletManagersFacade, + dispatchers = TestingCoroutineDispatcherProvider(), + ), + recordPrivateCalls = true, + ) + every { mockk().contractAddress } returns mockedContractAddress + } + + @Test + fun `createEnterTransactions returns deploy-approve-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns EthereumUtils.ZERO_ADDRESS + coEvery { walletManager.getYieldSupplyStatus(any()) } returns null + coEvery { walletManager.isAllowedToSpend(any()) } returns false + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 3 transactions are returned: deploy, approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - deploy + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getDeployCallData( + walletAddress = walletManager.wallet.address, + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - approve + val secondExpectedCallData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = yieldContractAddress, + amount = null, + blockchain = Blockchain.EthereumTestnet, + ) + val secondTransaction = result[1] + + Truth.assertThat(secondTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((secondTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(secondExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[2] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns init-approve-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = false, + isInitialized = false, + maxNetworkFee = BigDecimal.TEN, + ) + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 3 transactions are returned: init token, approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - init token + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getInitTokenCallData( + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - approve + val secondExpectedCallData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = yieldContractAddress, + amount = null, + blockchain = Blockchain.EthereumTestnet, + ) + val secondTransaction = result[1] + + Truth.assertThat(secondTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((secondTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(secondExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[2] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns reactivate-approve-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = false, + isInitialized = true, + maxNetworkFee = BigDecimal.TEN, + ) + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 3 transactions are returned: reactivate token, approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - reactivate token + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData( + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - approve + val secondExpectedCallData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = yieldContractAddress, + amount = null, + blockchain = Blockchain.EthereumTestnet, + ) + val secondTransaction = result[1] + + Truth.assertThat(secondTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((secondTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(secondExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[2] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns reactivate-enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = false, + isInitialized = true, + maxNetworkFee = BigDecimal.TEN, + ) + coEvery { walletManager.isAllowedToSpend(any()) } returns true + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that 2 transactions are returned: approve, enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - reactivate token + val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData( + tokenContractAddress = mockedContractAddress, + maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency), + ) + val firstTransaction = result.first() + + Truth.assertThat(firstTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((firstTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(firstExpectedCallData.data) + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[1] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createEnterTransactions returns enter transactions`() = runTest { + coEvery { walletManager.getYieldContract() } returns yieldContractAddress + coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress + coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus( + isActive = true, + isInitialized = true, + maxNetworkFee = BigDecimal.TEN, + ) + coEvery { walletManager.isAllowedToSpend(any()) } returns true + + val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus) + + // Assert that transaction is returned: enter + Truth.assertThat(result).isNotNull() + Truth.assertThat(result).isNotEmpty() + + // Check transaction - enter + val thirdExpectedCallData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(mockedContractAddress) + val thirdTransaction = result[0] + + Truth.assertThat(thirdTransaction.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((thirdTransaction.extras as EthereumTransactionExtras).callData?.data) + .isEqualTo(thirdExpectedCallData.data) + } + + @Test + fun `createExitTransaction returns valid transaction`() = runTest { + val expectedCallData = + YieldSupplyContractCallDataProviderFactory.getExitCallData(mockedContractAddress) + + val yieldSupplyStatus = mockk(relaxed = true) + + val result = repository.createExitTransaction(userWalletId, cryptoCurrency, yieldSupplyStatus, null) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java) + Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data) + } +} \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt b/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt index 1bfffa0dc3..677c7b354a 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt @@ -7,18 +7,26 @@ import java.math.BigDecimal import com.tangem.blockchain.common.Amount as SdkAmount /** Converts `BigDecimal` [cryptoCurrency] to [SdkAmount] */ -fun BigDecimal.convertToSdkAmount(cryptoCurrency: CryptoCurrency): SdkAmount = SdkAmount( +fun BigDecimal.convertToSdkAmount( + cryptoCurrency: CryptoCurrency, + amountType: AmountType = getAmountTypeFromCryptoCurrency(cryptoCurrency), +): SdkAmount = SdkAmount( currencySymbol = cryptoCurrency.symbol, value = this, decimals = cryptoCurrency.decimals, - type = when (cryptoCurrency) { - is CryptoCurrency.Coin -> AmountType.Coin - is CryptoCurrency.Token -> AmountType.Token( - token = Token( - symbol = cryptoCurrency.symbol, - contractAddress = cryptoCurrency.contractAddress, - decimals = cryptoCurrency.decimals, - ), - ) - }, -) \ No newline at end of file + type = amountType, +) + +/** + * Converts [CryptoCurrency] to [AmountType] based on its type + */ +private fun getAmountTypeFromCryptoCurrency(cryptoCurrency: CryptoCurrency) = when (cryptoCurrency) { + is CryptoCurrency.Coin -> AmountType.Coin + is CryptoCurrency.Token -> AmountType.Token( + token = Token( + symbol = cryptoCurrency.symbol, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + ) +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt index a5e7e04e36..f2b3a91661 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/FeeRepository.kt @@ -1,10 +1,21 @@ package com.tangem.domain.transaction import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet interface FeeRepository { /** Returns if fee is approximate for current [networkId] */ fun isFeeApproximate(networkId: Network.ID, amountType: AmountType): Boolean + + /** Returns fee calculated for the transaction [transactionData] */ + suspend fun calculateFee( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + transactionData: TransactionData, + ): TransactionFee } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt new file mode 100644 index 0000000000..058cfa752a --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/FeeErrorResolver.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.transaction.error + +interface FeeErrorResolver { + + fun resolve(throwable: Throwable): GetFeeError +} \ No newline at end of file diff --git a/domain/yield-supply/build.gradle.kts b/domain/yield-supply/build.gradle.kts new file mode 100644 index 0000000000..e10c9dba42 --- /dev/null +++ b/domain/yield-supply/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.domain.yield.supply" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.transaction.models) + implementation(projects.domain.transaction) + implementation(projects.domain.legacy) + + /** Tandem SDK */ + implementation(tangemDeps.blockchain) + + /** Other */ + implementation(deps.arrow.core) + + /** tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt new file mode 100644 index 0000000000..34a17c1905 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.yield.supply + +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus + +interface YieldSupplyTransactionRepository { + + suspend fun createEnterTransactions( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): List + + suspend fun createExitTransaction( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + yieldSupplyStatus: YieldSupplyStatus, + fee: Fee?, + ): TransactionData.Uncompiled +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt new file mode 100644 index 0000000000..0c7dd8bd8c --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEstimateEnterFeeUseCase.kt @@ -0,0 +1,58 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.transaction.error.GetFeeError + +class YieldSupplyEstimateEnterFeeUseCase( + private val feeRepository: FeeRepository, + private val feeErrorResolver: FeeErrorResolver, +) { + suspend operator fun invoke( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + transactionDataList: List, + ): Either> = Either.catch { + transactionDataList.mapIndexed { index, transaction -> + val fee = feeRepository.calculateFee( + userWallet = userWallet, + cryptoCurrency = cryptoCurrency, + transactionData = transaction, + ).normal + + if (index == transactionDataList.lastIndex) { // todo yield supply replace with check for contract + transaction.copy(fee = fee.fixFee(cryptoCurrency)) + } else { + transaction.copy(fee = fee) + } + } + }.mapLeft(feeErrorResolver::resolve) + + private fun Fee.fixFee(cryptoCurrency: CryptoCurrency) = when (this) { + is Fee.Ethereum.Legacy -> copy( + gasLimit = ETHEREUM_CONSTANT_GAS_LIMIT, + amount = amount.copy( + value = gasPrice.multiply(ETHEREUM_CONSTANT_GAS_LIMIT) + .toBigDecimal().movePointLeft(cryptoCurrency.decimals), + ), + ) + is Fee.Ethereum.EIP1559 -> copy( + gasLimit = ETHEREUM_CONSTANT_GAS_LIMIT, + amount = amount.copy( + value = maxFeePerGas.multiply(ETHEREUM_CONSTANT_GAS_LIMIT) + .toBigDecimal().movePointLeft(cryptoCurrency.decimals), + ), + ) + else -> this + } + + private companion object { + // Using constant gas limit to avoid fee calculation errors when contract address is not deployed yet + val ETHEREUM_CONSTANT_GAS_LIMIT = 350_000.toBigInteger() + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt new file mode 100644 index 0000000000..b5e73e680f --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStartEarningUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository + +class YieldSupplyStartEarningUseCase( + private val yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): Either> = Either.catch { + yieldSupplyTransactionRepository.createEnterTransactions( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt new file mode 100644 index 0000000000..aab058196b --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyStopEarningUseCase.kt @@ -0,0 +1,29 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository + +class YieldSupplyStopEarningUseCase( + private val yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + fee: Fee?, + ): Either = Either.catch { + val yieldTokenStatus = cryptoCurrencyStatus.value.yieldSupplyStatus ?: error("") + val cryptoCurrency = cryptoCurrencyStatus.currency + + yieldSupplyTransactionRepository.createExitTransaction( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + yieldSupplyStatus = yieldTokenStatus, + fee = null, + ) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt new file mode 100644 index 0000000000..a38b1d1f15 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyEstimateEnterFeeUseCaseTest.kt @@ -0,0 +1,172 @@ +package com.tangem.domain.yield.supply + +import arrow.core.Either +import com.google.common.truth.Truth +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.FeeRepository +import com.tangem.domain.transaction.error.FeeErrorResolver +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.BigInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class YieldSupplyEstimateEnterFeeUseCaseTest { + private val feeRepository: FeeRepository = mockk() + private val feeErrorResolver: FeeErrorResolver = mockk() + private val useCase = YieldSupplyEstimateEnterFeeUseCase(feeRepository, feeErrorResolver) + + private val userWallet: UserWallet = mockk() + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { decimals } returns 18 + } + + private fun ethLegacyFee( + gasPrice: BigInteger = BigInteger.valueOf(100_000_000_000L), + gasLimit: BigInteger = BigInteger.valueOf(21_000), + ) = Fee.Ethereum.Legacy( + gasPrice = gasPrice, + gasLimit = gasLimit, + amount = BigDecimal.ONE.convertToSdkAmount(cryptoCurrency), + ) + + private fun ethEip1559Fee( + maxFeePerGas: BigInteger = BigInteger.valueOf(100_000_000_000L), + gasLimit: BigInteger = BigInteger.valueOf(21_000), + ) = Fee.Ethereum.EIP1559( + maxFeePerGas = maxFeePerGas, + priorityFee = BigInteger.ONE, + gasLimit = gasLimit, + amount = BigDecimal.ONE.convertToSdkAmount(cryptoCurrency), + ) + + private fun uncompiled(fee: Fee) = TransactionData.Uncompiled( + fee = fee, + amount = BigDecimal.ONE.convertToSdkAmount(cryptoCurrency), + contractAddress = null, + sourceAddress = "0x1234567890123456789012345678901234567890", + destinationAddress = "0x1234567890123456789012345678901234567890", + ) + + @Test + fun `test 1 transaction uses constant gas limit Legacy`() = runTest { + val fee = TransactionFee.Single(ethLegacyFee()) + val tx = uncompiled(ethLegacyFee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returns fee + + val result = useCase(userWallet, cryptoCurrency, listOf(tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(1) + + val lastFee = txs.last().fee as Fee.Ethereum.Legacy + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 2 transactions, only last uses constant gas limit Legacy`() = runTest { + val fee = TransactionFee.Single(ethLegacyFee()) + val tx = uncompiled(ethLegacyFee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(2) + + val firstFee = txs.first().fee as Fee.Ethereum.Legacy + val lastFee = txs.last().fee as Fee.Ethereum.Legacy + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 3 transactions, only last uses constant gas limit Legacy`() = runTest { + val fee = TransactionFee.Single(ethLegacyFee()) + val tx = uncompiled(ethLegacyFee()) + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(3) + + val firstFee = txs[0].fee as Fee.Ethereum.Legacy + val secondFee = txs[1].fee as Fee.Ethereum.Legacy + val lastFee = txs[2].fee as Fee.Ethereum.Legacy + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(secondFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 1 transaction uses constant gas limit Eip1559`() = runTest { + val fee = TransactionFee.Single(ethEip1559Fee()) + val tx = uncompiled(ethEip1559Fee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returns fee + + val result = useCase(userWallet, cryptoCurrency, listOf(tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(1) + + val lastFee = txs.last().fee as Fee.Ethereum.EIP1559 + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 2 transactions, only last uses constant gas limit Eip1559`() = runTest { + val fee = TransactionFee.Single(ethEip1559Fee()) + val tx = uncompiled(ethEip1559Fee()) + + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(2) + + val firstFee = txs.first().fee as Fee.Ethereum.EIP1559 + val lastFee = txs.last().fee as Fee.Ethereum.EIP1559 + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } + + @Test + fun `test 3 transactions, only last uses constant gas limit Eip1559`() = runTest { + val fee = TransactionFee.Single(ethEip1559Fee()) + val tx = uncompiled(ethEip1559Fee()) + coEvery { feeRepository.calculateFee(any(), any(), any()) } returnsMany listOf(fee, fee, fee) + + val result = useCase(userWallet, cryptoCurrency, listOf(tx, tx, tx)) + Truth.assertThat(result.isRight()).isTrue() + + val txs = (result as Either.Right).value + Truth.assertThat(txs.size).isEqualTo(3) + + val firstFee = txs[0].fee as Fee.Ethereum.EIP1559 + val secondFee = txs[1].fee as Fee.Ethereum.EIP1559 + val lastFee = txs[2].fee as Fee.Ethereum.EIP1559 + Truth.assertThat(firstFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(secondFee.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + Truth.assertThat(lastFee.gasLimit).isEqualTo(BigInteger.valueOf(350_000)) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 305ea20cc8..365023aad8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -350,6 +350,7 @@ include(":domain:swap") include(":domain:swap:models") include(":domain:wallet-manager") include(":domain:wallet-manager:models") +include(":domain:yield-supply") // endregion Domain modules // region Data modules @@ -383,5 +384,6 @@ include(":data:blockaid") include(":data:swap") include(":data:express") include(":data:wallet-manager") +include(":data:yield-supply") // endregion Data modules include(":features:tangempay:onboarding") \ No newline at end of file