From 1ce3399a0281c4a0a28a627391cbee1eb39a0ce0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 May 2026 11:36:41 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/data/pay/di/TangemPayDataModule.kt | 9 +- .../DefaultTangemPayWithdrawRepository.kt | 50 ++- .../DefaultTangemPayWithdrawUseCase.kt | 3 - ...DefaultTangemPayWithdrawWithSwapUseCase.kt | 33 ++ .../DefaultTangemPayWithdrawRepositoryTest.kt | 389 ++++++++++++++++++ .../repository/TangemPayWithdrawRepository.kt | 9 +- .../tangempay/TangemPayWithdrawUseCase.kt | 2 - .../TangemPayWithdrawWithSwapUseCase.kt | 20 + features/swap/domain/build.gradle.kts | 1 + .../domain/transfer/SwapTransferInteractor.kt | 8 + .../transfer/SwapTransferInteractorImpl.kt | 26 +- .../SwapTransferInteractorImplTest.kt | 44 ++ .../tangem/feature/swap/model/SwapModel.kt | 162 +++++--- .../ui/transfer/SwapTransferStateBuilder.kt | 62 ++- .../transfer/SwapTransferStateBuilderTest.kt | 137 ++++++ 15 files changed, 890 insertions(+), 65 deletions(-) create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawWithSwapUseCase.kt create mode 100644 data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepositoryTest.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawWithSwapUseCase.kt diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 0c6f02473e..7cfc299b35 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -15,6 +15,7 @@ import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase +import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawWithSwapUseCase import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM @@ -33,10 +34,10 @@ import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase import com.tangem.domain.pay.usecase.StartTangemPayOrderPollingUseCase import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase -import com.tangem.domain.pay.usecase.* import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase +import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -84,6 +85,12 @@ internal interface TangemPayDataModule { impl: DefaultGetTangemPayCurrencyStatusUseCase, ): GetTangemPayCurrencyStatusUseCase + @Binds + @Singleton + fun bindTangemPayWithdrawWithSwapUseCase( + impl: DefaultTangemPayWithdrawWithSwapUseCase, + ): TangemPayWithdrawWithSwapUseCase + @Binds @Singleton fun bindTangemPayWithdrawUseCase(impl: DefaultTangemPayWithdrawUseCase): TangemPayWithdrawUseCase diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt index ffcc761c68..0a6842264b 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt @@ -2,6 +2,7 @@ package com.tangem.data.pay.repository import arrow.core.Either import arrow.core.left +import arrow.core.right import com.tangem.core.error.UniversalError import com.tangem.data.common.quote.QuotesFetcher import com.tangem.datasource.api.pay.TangemPayApi @@ -12,10 +13,7 @@ import com.tangem.datasource.local.visa.TangemPayStorage 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.pay.TangemPayWithdrawExchangeState -import com.tangem.domain.pay.TangemPayWithdrawState -import com.tangem.domain.pay.WithdrawalResult -import com.tangem.domain.pay.WithdrawalSignatureResult +import com.tangem.domain.pay.* import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.repository.CustomerOrderRepository @@ -60,7 +58,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor( private val pollingJobs = mutableMapOf() private val pollingMutex = Mutex() - override suspend fun withdraw( + override suspend fun withdrawWithSwap( userWallet: UserWallet, receiverAddress: String, cryptoAmount: BigDecimal, @@ -100,7 +98,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor( WithdrawalResult.Success } } - null -> return Either.Left(VisaApiError.SignWithdrawError) + null -> Either.Left(VisaApiError.SignWithdrawError) } } } @@ -223,6 +221,46 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor( } } + override suspend fun withdraw( + userWallet: UserWallet, + receiverAddress: String, + cryptoAmount: BigDecimal, + cryptoCurrencyId: CryptoCurrency.RawID, + ): Either { + val amountInCents = getAmountInCents(cryptoAmount, cryptoCurrencyId) + if (amountInCents.isNullOrEmpty()) return VisaApiError.WithdrawalDataError.left() + + return requestHelper.performRequest(userWallet.walletId) { authHeader -> + val request = WithdrawDataRequest(amountInCents = amountInCents, recipientAddress = receiverAddress) + tangemPayApi.getWithdrawData(authHeader = authHeader, body = request) + }.map { data -> + val result = data.result ?: return VisaApiError.WithdrawalDataError.left() + val signatureResult = authDataSource.getWithdrawalSignature( + userWallet = userWallet, + hash = result.hash, + ).getOrNull() + return when (signatureResult) { + is WithdrawalSignatureResult.Cancelled -> WithdrawalResult.Cancelled.right() + is WithdrawalSignatureResult.Success -> requestHelper.performRequest( + userWalletId = userWallet.walletId, + ) { authHeader -> + val request = WithdrawRequest( + amountInCents = amountInCents, + recipientAddress = receiverAddress, + adminSalt = result.salt, + senderAddress = result.senderAddress, + adminSignature = signatureResult.signature.addHexPrefix(), + ) + tangemPayApi.withdraw(authHeader = authHeader, body = request) + }.fold( + ifLeft = { VisaApiError.WithdrawError.left() }, + ifRight = { WithdrawalResult.Success.right() }, + ) + null -> VisaApiError.SignWithdrawError.left() + } + } + } + override suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean { val orderId = tangemPayStorage.getActiveWithdrawOrderId(userWalletId) if (orderId.isNullOrEmpty()) return false diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawUseCase.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawUseCase.kt index 73f569a219..ba502910bc 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawUseCase.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawUseCase.kt @@ -4,7 +4,6 @@ import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.pay.repository.TangemPayWithdrawRepository import com.tangem.domain.tangempay.TangemPayWithdrawUseCase @@ -20,14 +19,12 @@ internal class DefaultTangemPayWithdrawUseCase @Inject constructor( cryptoAmount: BigDecimal, cryptoCurrencyId: CryptoCurrency.RawID, receiverCexAddress: String, - exchangeData: TangemPayWithdrawExchangeState, ): Either { return repository.withdraw( userWallet = userWallet, cryptoAmount = cryptoAmount, receiverAddress = receiverCexAddress, cryptoCurrencyId = cryptoCurrencyId, - exchangeData = exchangeData, ) } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawWithSwapUseCase.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawWithSwapUseCase.kt new file mode 100644 index 0000000000..e2bfa860b0 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawWithSwapUseCase.kt @@ -0,0 +1,33 @@ +package com.tangem.data.pay.usecase + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.TangemPayWithdrawExchangeState +import com.tangem.domain.pay.WithdrawalResult +import com.tangem.domain.pay.repository.TangemPayWithdrawRepository +import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase +import java.math.BigDecimal +import javax.inject.Inject + +internal class DefaultTangemPayWithdrawWithSwapUseCase @Inject constructor( + private val repository: TangemPayWithdrawRepository, +) : TangemPayWithdrawWithSwapUseCase { + + override suspend fun invoke( + userWallet: UserWallet, + cryptoAmount: BigDecimal, + cryptoCurrencyId: CryptoCurrency.RawID, + receiverCexAddress: String, + exchangeData: TangemPayWithdrawExchangeState, + ): Either { + return repository.withdrawWithSwap( + userWallet = userWallet, + cryptoAmount = cryptoAmount, + receiverAddress = receiverCexAddress, + cryptoCurrencyId = cryptoCurrencyId, + exchangeData = exchangeData, + ) + } +} \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepositoryTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepositoryTest.kt new file mode 100644 index 0000000000..1fa6e31cbd --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepositoryTest.kt @@ -0,0 +1,389 @@ +package com.tangem.data.pay.repository + +import arrow.core.left +import arrow.core.right +import com.tangem.common.test.TestAppCoroutineScope +import com.tangem.data.common.quote.QuotesFetcher +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.response.WithdrawDataResponse +import com.tangem.datasource.api.pay.models.response.WithdrawResponse +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.local.visa.TangemPayStorage +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.pay.TangemPayWithdrawExchangeState +import com.tangem.domain.pay.TangemPayWithdrawState +import com.tangem.domain.pay.WithdrawalResult +import com.tangem.domain.pay.WithdrawalSignatureResult +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.pay.model.OrderData +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.repository.CustomerOrderRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.feature.swap.domain.api.SwapRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DefaultTangemPayWithdrawRepositoryTest { + + private val tangemPayApi: TangemPayApi = mockk() + private val requestHelper: TangemPayRequestPerformer = mockk() + private val authDataSource: TangemPayAuthDataSource = mockk() + private val quotesFetcher: QuotesFetcher = mockk() + private val tangemPayStorage: TangemPayStorage = mockk(relaxUnitFun = true) + private val swapRepository: SwapRepository = mockk() + private val orderRepository: CustomerOrderRepository = mockk() + + private val userWalletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { + every { walletId } returns userWalletId + } + + private val cryptoCurrencyId = CryptoCurrency.RawID(CURRENCY_ID) + private val exchangeData = TangemPayWithdrawExchangeState( + txId = "txId", + fromNetwork = "ETH", + fromAddress = "0xFrom", + payInAddress = "0xPayIn", + payInExtraId = null, + ) + + private val orderWithoutHash = OrderData( + customerId = "customer", + status = OrderStatus.PROCESSING, + withdrawTxHash = null, + ) + private val orderWithHash = orderWithoutHash.copy(withdrawTxHash = TX_HASH) + + @BeforeEach + fun setUp() { + // Valid fiat rate so amountInCents resolves to a non-empty value. + coEvery { + quotesFetcher.fetch(fiatCurrencyId = any(), currencyId = any(), field = any()) + } returns QuotesResponse( + quotes = mapOf(CURRENCY_ID to QuotesResponse.Quote.EMPTY.copy(price = BigDecimal.ONE)), + ).right() + + // performRequest is treated as a transparent pass-through: it invokes the request block and + // maps the ApiResponse to Either, so each test can drive behaviour via the TangemPayApi mock. + coEvery { + requestHelper.performRequest(userWalletId = any(), requestBlock = any()) + } coAnswers { + val block = secondArg ApiResponse>() + when (val response = block(AUTH_HEADER)) { + is ApiResponse.Success -> response.data.right() + is ApiResponse.Error -> VisaApiError.WithdrawError.left() + } + } + + coEvery { tangemPayApi.getWithdrawData(any(), any()) } returns ApiResponse.Success( + WithdrawDataResponse( + result = WithdrawDataResponse.Result(hash = "hash", salt = "salt", senderAddress = "sender"), + ), + ) + coEvery { tangemPayApi.withdraw(any(), any()) } returns ApiResponse.Success( + WithdrawResponse( + result = WithdrawResponse.Result(orderId = ORDER_ID, status = "NEW", type = "withdraw"), + ), + ) + coEvery { + authDataSource.getWithdrawalSignature(any(), any()) + } returns WithdrawalSignatureResult.Success(SIGNATURE).right() + coEvery { swapRepository.exchangeSent(any(), any(), any(), any(), any(), any(), any()) } returns Unit.right() + } + + // region withdrawWithSwap + + @Test + fun `GIVEN amountInCents is null WHEN withdrawWithSwap THEN return WithdrawalDataError`() = runTest { + coEvery { + quotesFetcher.fetch(fiatCurrencyId = any(), currencyId = any(), field = any()) + } returns QuotesFetcher.Error.CacheOperationError.left() + + val result = createRepository().withdrawWithSwap() + + Assertions.assertEquals(VisaApiError.WithdrawalDataError.left(), result) + coVerify(exactly = 0) { tangemPayApi.getWithdrawData(any(), any()) } + } + + @Test + fun `GIVEN getWithdrawData result is null WHEN withdrawWithSwap THEN return WithdrawalDataError`() = runTest { + coEvery { tangemPayApi.getWithdrawData(any(), any()) } returns ApiResponse.Success( + WithdrawDataResponse(result = null), + ) + + val result = createRepository().withdrawWithSwap() + + Assertions.assertEquals(VisaApiError.WithdrawalDataError.left(), result) + coVerify(exactly = 0) { authDataSource.getWithdrawalSignature(any(), any()) } + } + + @Test + fun `GIVEN withdrawal signature is null WHEN withdrawWithSwap THEN return SignWithdrawError`() = runTest { + coEvery { authDataSource.getWithdrawalSignature(any(), any()) } returns RuntimeException("error").left() + + val result = createRepository().withdrawWithSwap() + + Assertions.assertEquals(VisaApiError.SignWithdrawError.left(), result) + coVerify(exactly = 0) { tangemPayApi.withdraw(any(), any()) } + } + + @Test + fun `GIVEN withdrawal signature is Cancelled WHEN withdrawWithSwap THEN return Cancelled`() = runTest { + coEvery { authDataSource.getWithdrawalSignature(any(), any()) } returns WithdrawalSignatureResult.Cancelled.right() + + val result = createRepository().withdrawWithSwap() + + Assertions.assertEquals(WithdrawalResult.Cancelled.right(), result) + coVerify(exactly = 0) { tangemPayApi.withdraw(any(), any()) } + } + + @Test + fun `GIVEN withdraw returns error WHEN withdrawWithSwap THEN return WithdrawError`() = runTest { + coEvery { tangemPayApi.withdraw(any(), any()) } returns + ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse + + val result = createRepository().withdrawWithSwap() + + Assertions.assertEquals(VisaApiError.WithdrawError.left(), result) + coVerify(exactly = 0) { orderRepository.getOrderData(any(), any()) } + } + + @Test + fun `GIVEN no txHash on every attempt WHEN withdrawWithSwap THEN polling deletes order after max attempts`() = + runTest { + coEvery { orderRepository.getOrderData(any(), any()) } returns orderWithoutHash.right() + + val result = createRepository().withdrawWithSwap() + advanceUntilIdle() + + Assertions.assertEquals(WithdrawalResult.Success.right(), result) + // 1 initial check + MAX_POLLING_ATTEMPTS (6) polling attempts. + coVerify(exactly = 7) { orderRepository.getOrderData(userWalletId, ORDER_ID) } + coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) } + coVerify(exactly = 0) { swapRepository.exchangeSent(any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `GIVEN getOrderData throws while polling WHEN withdrawWithSwap THEN polling deletes order`() = runTest { + coEvery { + orderRepository.getOrderData(any(), any()) + } returns orderWithoutHash.right() andThenThrows RuntimeException("boom") + + val result = createRepository().withdrawWithSwap() + advanceUntilIdle() + + Assertions.assertEquals(WithdrawalResult.Success.right(), result) + coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) } + coVerify(exactly = 0) { swapRepository.exchangeSent(any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `GIVEN txHash appears on the last attempt WHEN withdrawWithSwap THEN polling finalizes the withdrawal`() = + runTest { + // index 0 = initial check, 1..5 = polling attempts 1-5, 6 = polling attempt 6 (last) returns the hash. + coEvery { orderRepository.getOrderData(any(), any()) } returnsMany + List(size = 6) { orderWithoutHash.right() } + listOf(orderWithHash.right()) + + val result = createRepository().withdrawWithSwap() + advanceUntilIdle() + + Assertions.assertEquals(WithdrawalResult.Success.right(), result) + assertExchangeSent() + coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) } + } + + // endregion + + // region withdraw + + @Test + fun `GIVEN withdrawal signature is Cancelled WHEN withdraw THEN return Cancelled`() = runTest { + coEvery { authDataSource.getWithdrawalSignature(any(), any()) } returns WithdrawalSignatureResult.Cancelled.right() + + val result = createRepository().withdraw() + + Assertions.assertEquals(WithdrawalResult.Cancelled.right(), result) + coVerify(exactly = 0) { tangemPayApi.withdraw(any(), any()) } + } + + @Test + fun `GIVEN withdraw succeeds WHEN withdraw THEN return Success`() = runTest { + val result = createRepository().withdraw() + + Assertions.assertEquals(WithdrawalResult.Success.right(), result) + coVerify { tangemPayApi.withdraw(any(), any()) } + } + + // endregion + + // region hasWithdrawOrder + + @Test + fun `GIVEN no active order id WHEN hasWithdrawOrder THEN return false`() = runTest { + coEvery { tangemPayStorage.getActiveWithdrawOrderId(userWalletId) } returns null + + val result = createRepository().hasWithdrawOrder(userWalletId) + + Assertions.assertFalse(result) + coVerify(exactly = 0) { orderRepository.getOrderData(any(), any()) } + } + + @Test + fun `GIVEN order is not active WHEN hasWithdrawOrder THEN delete active order and return false`() = runTest { + coEvery { tangemPayStorage.getActiveWithdrawOrderId(userWalletId) } returns ORDER_ID + coEvery { + orderRepository.getOrderData(userWalletId, ORDER_ID) + } returns orderWithoutHash.copy(status = OrderStatus.COMPLETED).right() + + val result = createRepository().hasWithdrawOrder(userWalletId) + + Assertions.assertFalse(result) + coVerify { tangemPayStorage.deleteActiveWithdrawOrder(userWalletId) } + } + + @Test + fun `GIVEN order is active WHEN hasWithdrawOrder THEN return true and keep active order`() = runTest { + coEvery { tangemPayStorage.getActiveWithdrawOrderId(userWalletId) } returns ORDER_ID + coEvery { + orderRepository.getOrderData(userWalletId, ORDER_ID) + } returns orderWithoutHash.copy(status = OrderStatus.NEW).right() + + val result = createRepository().hasWithdrawOrder(userWalletId) + + Assertions.assertTrue(result) + coVerify(exactly = 0) { tangemPayStorage.deleteActiveWithdrawOrder(userWalletId) } + } + + // endregion + + // region pollWithdrawOrdersIfNeeds + + @Test + fun `GIVEN stored hash is null and order hash appears on third attempt WHEN poll THEN finalize the withdrawal`() = + runTest { + coEvery { tangemPayStorage.getWithdrawOrders(userWalletId) } returns listOf(storedOrder(txHash = null)) + // index 0 = initial fetch, 1..2 = polling attempts 1-2, 3 = polling attempt 3 returns the hash. + coEvery { orderRepository.getOrderData(any(), any()) } returnsMany + List(size = 3) { orderWithoutHash.right() } + listOf(orderWithHash.right()) + + createRepository().pollWithdrawOrdersIfNeeds(userWallet) + advanceUntilIdle() + + coVerify(exactly = 4) { orderRepository.getOrderData(userWalletId, ORDER_ID) } + assertExchangeSent() + coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) } + } + + @Test + fun `GIVEN stored hash is null and order already has hash WHEN poll THEN finalize without polling`() = runTest { + coEvery { tangemPayStorage.getWithdrawOrders(userWalletId) } returns listOf(storedOrder(txHash = null)) + coEvery { orderRepository.getOrderData(userWalletId, ORDER_ID) } returns orderWithHash.right() + + createRepository().pollWithdrawOrdersIfNeeds(userWallet) + advanceUntilIdle() + + coVerify(exactly = 1) { orderRepository.getOrderData(userWalletId, ORDER_ID) } + assertExchangeSent() + coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) } + } + + @Test + fun `GIVEN stored hash has value WHEN poll THEN finalize without fetching the order`() = runTest { + coEvery { tangemPayStorage.getWithdrawOrders(userWalletId) } returns listOf(storedOrder(txHash = TX_HASH)) + + createRepository().pollWithdrawOrdersIfNeeds(userWallet) + advanceUntilIdle() + + coVerify(exactly = 0) { orderRepository.getOrderData(any(), any()) } + assertExchangeSent() + coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) } + } + + @Test + fun `GIVEN two identical orders WHEN poll THEN only a single polling job runs for the same order`() = runTest { + val duplicatedOrder = storedOrder(txHash = null) + coEvery { + tangemPayStorage.getWithdrawOrders(userWalletId) + } returns listOf(duplicatedOrder, duplicatedOrder) + coEvery { orderRepository.getOrderData(any(), any()) } returns orderWithoutHash.right() + + createRepository().pollWithdrawOrdersIfNeeds(userWallet) + advanceUntilIdle() + + // 2 initial fetches (one per order) + a single deduplicated polling job of 6 attempts = 8. + coVerify(exactly = 8) { orderRepository.getOrderData(userWalletId, ORDER_ID) } + coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) } + } + + // endregion + + private fun assertExchangeSent() { + coVerify { + swapRepository.exchangeSent( + userWallet = userWallet, + txId = exchangeData.txId, + fromNetwork = exchangeData.fromNetwork, + fromAddress = exchangeData.fromAddress, + payInAddress = exchangeData.payInAddress, + txHash = TX_HASH, + payInExtraId = exchangeData.payInExtraId, + ) + } + } + + private fun storedOrder(txHash: String?) = TangemPayWithdrawState( + orderId = ORDER_ID, + exchangeData = exchangeData, + txHash = txHash, + ) + + private suspend fun DefaultTangemPayWithdrawRepository.withdrawWithSwap() = withdrawWithSwap( + userWallet = userWallet, + receiverAddress = RECEIVER_ADDRESS, + cryptoAmount = BigDecimal("1.5"), + cryptoCurrencyId = cryptoCurrencyId, + exchangeData = exchangeData, + ) + + private suspend fun DefaultTangemPayWithdrawRepository.withdraw() = withdraw( + userWallet = userWallet, + receiverAddress = RECEIVER_ADDRESS, + cryptoAmount = BigDecimal("1.5"), + cryptoCurrencyId = cryptoCurrencyId, + ) + + private fun TestScope.createRepository() = DefaultTangemPayWithdrawRepository( + tangemPayApi = tangemPayApi, + requestHelper = requestHelper, + authDataSource = authDataSource, + quotesFetcher = quotesFetcher, + tangemPayStorage = tangemPayStorage, + swapRepository = swapRepository, + orderRepository = orderRepository, + withdrawPollingScope = TestAppCoroutineScope(this), + ) + + private companion object { + const val CURRENCY_ID = "ethereum" + const val ORDER_ID = "order-1" + const val TX_HASH = "0xTxHash" + const val SIGNATURE = "0xSignature" + const val AUTH_HEADER = "auth-header" + const val RECEIVER_ADDRESS = "0xReceiver" + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt index e2b28cdb0c..e88984ceb4 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt @@ -11,7 +11,7 @@ import java.math.BigDecimal interface TangemPayWithdrawRepository { - suspend fun withdraw( + suspend fun withdrawWithSwap( userWallet: UserWallet, receiverAddress: String, cryptoAmount: BigDecimal, @@ -19,6 +19,13 @@ interface TangemPayWithdrawRepository { exchangeData: TangemPayWithdrawExchangeState, ): Either + suspend fun withdraw( + userWallet: UserWallet, + receiverAddress: String, + cryptoAmount: BigDecimal, + cryptoCurrencyId: CryptoCurrency.RawID, + ): Either + suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean suspend fun pollWithdrawOrdersIfNeeds(userWallet: UserWallet) diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawUseCase.kt index d98264599f..e2abfd227f 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawUseCase.kt @@ -4,7 +4,6 @@ import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.pay.WithdrawalResult import java.math.BigDecimal @@ -15,6 +14,5 @@ interface TangemPayWithdrawUseCase { cryptoAmount: BigDecimal, cryptoCurrencyId: CryptoCurrency.RawID, receiverCexAddress: String, - exchangeData: TangemPayWithdrawExchangeState, ): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawWithSwapUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawWithSwapUseCase.kt new file mode 100644 index 0000000000..7a496860a6 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawWithSwapUseCase.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.tangempay + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.TangemPayWithdrawExchangeState +import com.tangem.domain.pay.WithdrawalResult +import java.math.BigDecimal + +interface TangemPayWithdrawWithSwapUseCase { + + suspend operator fun invoke( + userWallet: UserWallet, + cryptoAmount: BigDecimal, + cryptoCurrencyId: CryptoCurrency.RawID, + receiverCexAddress: String, + exchangeData: TangemPayWithdrawExchangeState, + ): Either +} \ No newline at end of file diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 9712084d11..63505328ce 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(projects.core.ui) implementation(projects.core.datasource) implementation(projects.core.abTests) + implementation(projects.core.error) /** Feature Apis */ implementation(projects.features.wallet.api) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt index 68ceccef30..6731dc4b61 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -5,6 +5,8 @@ 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.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.SendTransactionError @@ -44,4 +46,10 @@ interface SwapTransferInteractor { fee: Fee, transactionFeeResult: TransactionFeeResult, ): Either + + suspend fun withdrawTangemPay( + userWallet: UserWallet, + cryptoAmount: BigDecimal, + toSwapCurrencyStatus: SwapCurrencyStatus, + ): Either } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 00ce438d27..2263812fe8 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -19,7 +19,9 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tangempay.TangemPayWithdrawUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck @@ -57,6 +59,7 @@ class SwapTransferInteractorImpl @Inject constructor( private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, + private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, ) : SwapTransferInteractor { override suspend fun updateTransfer( @@ -278,7 +281,28 @@ class SwapTransferInteractorImpl @Inject constructor( ) } - private fun getDataError(message: String): Either { + override suspend fun withdrawTangemPay( + userWallet: UserWallet, + cryptoAmount: BigDecimal, + toSwapCurrencyStatus: SwapCurrencyStatus, + ): Either { + val destination = toSwapCurrencyStatus.destinationAddress() ?: return getDataError( + message = "Destination address is null", + ) + val cryptoCurrencyId = toSwapCurrencyStatus.currency.id.rawCurrencyId ?: return getDataError( + message = "Crypto currency id should be null", + ) + return tangemPayWithdrawUseCase( + userWallet = userWallet, + cryptoAmount = cryptoAmount, + cryptoCurrencyId = cryptoCurrencyId, + receiverCexAddress = destination, + ).mapLeft { error -> + SendTransactionError.DataError("Tangem Pay withdrawal error code is ${error.errorCode}") + } + } + + private fun getDataError(message: String): Either { return SendTransactionError.DataError(message).left() } diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index c49a417619..864296e66f 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -16,7 +16,9 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tangempay.TangemPayWithdrawUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck @@ -56,6 +58,7 @@ internal class SwapTransferInteractorImplTest { private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk() private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() + private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase = mockk() private val sut = SwapTransferInteractorImpl( swapFeatureToggles = swapFeatureToggles, @@ -69,6 +72,7 @@ internal class SwapTransferInteractorImplTest { createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, getCurrencyCheckUseCase = getCurrencyCheckUseCase, isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, + tangemPayWithdrawUseCase = tangemPayWithdrawUseCase, ) @AfterEach @@ -613,6 +617,46 @@ internal class SwapTransferInteractorImplTest { // endregion + // region withdrawTangemPay + + @Test + fun `GIVEN valid destination and currency id WHEN withdrawTangemPay THEN return WithdrawalResult from use case`() = + runTest { + val userWallet: UserWallet = mockk() + val cryptoAmount = BigDecimal("1.5") + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + destinationAddress = DESTINATION_ADDRESS, + ) + coEvery { + tangemPayWithdrawUseCase( + userWallet = userWallet, + cryptoAmount = cryptoAmount, + cryptoCurrencyId = TO_RAW_CURRENCY_ID, + receiverCexAddress = DESTINATION_ADDRESS, + ) + } returns WithdrawalResult.Success.right() + + val result = sut.withdrawTangemPay( + userWallet = userWallet, + cryptoAmount = cryptoAmount, + toSwapCurrencyStatus = toCurrencyStatus, + ) + + assertThat(result).isEqualTo(WithdrawalResult.Success.right()) + coVerify { + tangemPayWithdrawUseCase( + userWallet = userWallet, + cryptoAmount = cryptoAmount, + cryptoCurrencyId = TO_RAW_CURRENCY_ID, + receiverCexAddress = DESTINATION_ADDRESS, + ) + } + } + + // endregion + // region shouldTransferInsteadOfSwap @Test diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index cd731174ee..7ca2eef0a1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -68,7 +68,7 @@ import com.tangem.domain.swap.models.PredefinedPercentAmount import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.swap.usecase.CalculateAmountUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase -import com.tangem.domain.tangempay.TangemPayWithdrawUseCase +import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase import com.tangem.domain.transaction.error.GetFeeError @@ -155,7 +155,7 @@ internal class SwapModel @Inject constructor( private val urlOpener: UrlOpener, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, - private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, + private val tangemPayWithdrawWithSwapUseCase: TangemPayWithdrawWithSwapUseCase, private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase, @@ -738,14 +738,18 @@ internal class SwapModel @Inject constructor( feePaidCryptoCurrencyStatus = feePaidCryptoCurrency, fee = selectedFee, ) - feeSelectorRepository.state.value = FeeSelectorUM.Loading - feeSelectorReloadTrigger.triggerUpdate() + if (isTangemPayWithdrawal()) { + refreshTransferUIStateIfNeeded() + } else { + feeSelectorRepository.state.value = FeeSelectorUM.Loading + feeSelectorReloadTrigger.triggerUpdate() + } } is SwapState.QuotesLoadedState, is SwapState.SwapError -> Unit } } - private fun refreshTransferUIStateAfterFeeUpdateIfNeeded( + private fun refreshTransferUIStateIfNeeded( feePaidCryptoCurrencyStatus: CryptoCurrencyStatus? = null, fee: Fee? = null, ) { @@ -774,6 +778,7 @@ internal class SwapModel @Inject constructor( uiStateHolder = uiState, feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, + isTangemPayWithdrawal = isTangemPayWithdrawal(), ) } } @@ -1287,61 +1292,122 @@ internal class SwapModel @Inject constructor( val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus val fee = (feeSelectorRepository.state.value as? FeeSelectorUM.Content)?.selectedFeeItem?.fee - if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null || fee == null) { - TangemLogger.e("onTransferClick: missing currency status or fee, aborting") + if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null) { + TangemLogger.e("onTransferClick: missing currency status, aborting") showAlert() return } val transferState = dataState.currentTransferState ?: return uiState = swapTransferStateBuilder.createTransferInProgressState(uiState) modelScope.launch(dispatchers.main) { - swapTransferInteractor.sendTransfer( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - sendingAmount = transferState.sendingAmount, - fee = fee, - transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) { - "It should be not null at this stage" - }, - ).fold( - ifLeft = { error -> - TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}") + when { + isTangemPayWithdrawal() -> withdrawTangemPay( + transferState = transferState, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + fee != null -> sendTransfer( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + transferState = transferState, + fee = fee, + ) + else -> { + TangemLogger.e("onTransferClick: Illegal state, aborting") showAlert() - }, - ifRight = { txHash -> - val txUrl = getExplorerTransactionUrlUseCase( - txHash = txHash, - currency = fromSwapCurrencyStatus.currency, - ).getOrElse { - TangemLogger.i("onTransferClick: tx hash explore not supported") - "" - } - updateWalletBalance() - uiState = swapTransferStateBuilder.createSuccessState( - uiState = uiState, - dataState = dataState, - appCurrency = selectedAppCurrencyFlow.value, - isAccountsMode = isAccountsMode, - txUrl = txUrl, - timestamp = System.currentTimeMillis(), - fee = null, - onExplorerClick = { - if (txUrl.isNotEmpty()) { - urlOpener.openUrl(txUrl) - } - }, - ) - router.replaceAll(SwapRoute.Success) - }, - ) + } + } } } + private suspend fun withdrawTangemPay( + transferState: SwapState.Transfer, + toSwapCurrencyStatus: SwapCurrencyStatus, + ) { + swapTransferInteractor.withdrawTangemPay( + userWallet = transferState.userWallet, + cryptoAmount = transferState.sendingAmount, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + .onLeft { error -> + TangemLogger.e( + messageString = "onTransferClick: withdrawTangemPay failed: ${error.getAnalyticsDescription()}", + ) + showAlert() + } + .onRight { result -> + when (result) { + WithdrawalResult.Cancelled -> startLoadingQuotesFromLastState() + WithdrawalResult.Success -> updateTransferModeTangemPayState() + } + } + } + + private fun updateTransferModeTangemPayState() { + uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState( + uiState = uiState, + dataState = dataState, + onExploreClick = { + val txUrl = uiState.successState?.txUrl.orEmpty() + if (txUrl.isNotEmpty()) { + urlOpener.openUrl(txUrl) + } + }, + ) + router.replaceAll(SwapRoute.Success) + } + + private suspend fun sendTransfer( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + transferState: SwapState.Transfer, + fee: Fee, + ) { + swapTransferInteractor.sendTransfer( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + sendingAmount = transferState.sendingAmount, + fee = fee, + transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) { + "It should be not null at this stage" + }, + ).fold( + ifLeft = { error -> + TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}") + showAlert() + }, + ifRight = { txHash -> + val txUrl = getExplorerTransactionUrlUseCase( + txHash = txHash, + currency = fromSwapCurrencyStatus.currency, + ).getOrElse { + TangemLogger.i("onTransferClick: tx hash explore not supported") + "" + } + updateWalletBalance() + uiState = swapTransferStateBuilder.createSuccessState( + uiState = uiState, + dataState = dataState, + appCurrency = selectedAppCurrencyFlow.value, + isAccountsMode = isAccountsMode, + txUrl = txUrl, + timestamp = System.currentTimeMillis(), + fee = null, + onExplorerClick = { + if (txUrl.isNotEmpty()) { + urlOpener.openUrl(txUrl) + } + }, + ) + router.replaceAll(SwapRoute.Success) + }, + ) + } + private suspend fun processTangemPayWithdrawal( fromSwapCurrencyStatus: SwapCurrencyStatus, swapTransactionState: SwapTransactionState.TangemPayWithdrawalData, ) { - tangemPayWithdrawUseCase( + tangemPayWithdrawWithSwapUseCase( userWallet = fromSwapCurrencyStatus.userWallet, cryptoAmount = swapTransactionState.cryptoAmount, cryptoCurrencyId = swapTransactionState.cryptoCurrencyId, @@ -2224,7 +2290,7 @@ internal class SwapModel @Inject constructor( if (newState is FeeSelectorUM.Error) { TangemLogger.e("loadFee: ${newState.error}, isHidden = true") - refreshTransferUIStateAfterFeeUpdateIfNeeded() + refreshTransferUIStateIfNeeded() uiState = stateBuilder.createFeeErrorState( uiStateHolder = uiState, quoteModel = dataState.getCurrentLoadedSwapState() ?: return, @@ -2234,7 +2300,7 @@ internal class SwapModel @Inject constructor( modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) } return } - refreshTransferUIStateAfterFeeUpdateIfNeeded( + refreshTransferUIStateIfNeeded( feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee, ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index e9cd7a543b..97e06f6f2b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -210,6 +210,9 @@ internal class SwapTransferStateBuilder @Inject constructor( } } + /** + * [isTangemPayWithdrawal] - if true - Tangem pay withdrawal done with no fee, skip fee nullability check + */ @Suppress("LongParameterList") fun updateTransferButtonEnableState( dataState: SwapProcessDataState, @@ -218,6 +221,7 @@ internal class SwapTransferStateBuilder @Inject constructor( uiStateHolder: SwapStateHolder, feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?, fee: Fee?, + isTangemPayWithdrawal: Boolean, ): SwapStateHolder { val notifications = notificationsFactory.getNotifications( transferState = transferState, @@ -229,7 +233,7 @@ internal class SwapTransferStateBuilder @Inject constructor( return uiStateHolder.copy( notifications = notifications, swapButton = uiStateHolder.swapButton.copy( - isEnabled = getTransferButtonEnabled(notifications, fee), + isEnabled = getTransferButtonEnabled(notifications, fee, isTangemPayWithdrawal), ), transferFooter = getSendingFooterText( dataState = dataState, @@ -240,8 +244,12 @@ internal class SwapTransferStateBuilder @Inject constructor( ) } - private fun getTransferButtonEnabled(notifications: ImmutableList, fee: Fee?): Boolean { - return fee != null && notifications.none { notification -> + private fun getTransferButtonEnabled( + notifications: ImmutableList, + fee: Fee?, + isTangemPayWithdrawal: Boolean, + ): Boolean { + return (fee != null || isTangemPayWithdrawal) && notifications.none { notification -> notification is SwapNotificationUM.Error || notification is NotificationUM.Error || notification is SwapNotificationUM.Warning.ExpressErrorWarning || notification is SwapNotificationUM.Warning.ExpressGeneralError || @@ -373,4 +381,52 @@ internal class SwapTransferStateBuilder @Inject constructor( ), ) } + + fun createTangemPayWithdrawalSuccessState( + uiState: SwapStateHolder, + dataState: SwapProcessDataState, + onExploreClick: () -> Unit, + ): SwapStateHolder { + val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) + val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) + val transferState = requireNotNull(dataState.currentTransferState) + val amountValue = transferState.sendingAmount + + val fiatAmount = getFormattedFiatAmount( + appCurrency = transferState.appCurrency, + amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amountValue), + ) + + return uiState.copy( + successState = SwapSuccessStateHolder( + timestamp = System.currentTimeMillis(), + txUrl = "", + providerName = stringReference(""), + providerType = stringReference(""), + shouldShowStatusButton = false, + isTransferMode = true, + providerIcon = "", + rate = TextReference.EMPTY, + fee = null, + fromTitle = getCardAccountTitle( + account = fromSwapCurrencyStatus.account, + isAccountsMode = transferState.isAccountsMode, + isFromCard = true, + ), + toTitle = getCardAccountTitle( + account = toSwapCurrencyStatus.account, + isAccountsMode = transferState.isAccountsMode, + isFromCard = false, + ), + fromTokenAmount = stringReference(amountValue.toString()), + toTokenAmount = stringReference(amountValue.toString()), + fromTokenFiatAmount = fiatAmount, + toTokenFiatAmount = fiatAmount, + fromTokenIconState = iconConverter.convert(fromSwapCurrencyStatus.status), + toTokenIconState = iconConverter.convert(toSwapCurrencyStatus.status), + onExploreButtonClick = onExploreClick, + onStatusButtonClick = {}, + ), + ) + } } \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index f9187e54f5..b33d90d5e3 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -317,6 +317,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = uiState, feePaidCryptoCurrencyStatus = null, fee = fee, + isTangemPayWithdrawal = false, ) assertThat(result.swapButton.isEnabled).isTrue() @@ -358,6 +359,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = uiState, feePaidCryptoCurrencyStatus = null, fee = fee, + isTangemPayWithdrawal = false, ) assertThat(result.transferFooter).isInstanceOf(TextReference.Combined::class.java) @@ -408,6 +410,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = uiState, feePaidCryptoCurrencyStatus = null, fee = fee, + isTangemPayWithdrawal = false, ) assertThat(result.transferFooter).isEqualTo( @@ -452,6 +455,7 @@ internal class SwapTransferStateBuilderTest { uiStateHolder = uiState, feePaidCryptoCurrencyStatus = null, fee = fee, + isTangemPayWithdrawal = false, ) assertThat(result.transferFooter).isEqualTo( @@ -518,6 +522,139 @@ internal class SwapTransferStateBuilderTest { ) } + @Test + fun `GIVEN null fee but tangem pay withdrawal WHEN updateTransferButtonEnableState THEN swap button is enabled with no footer`() = + runTest { + val transferState = buildTransferState( + fromAmount = BigDecimal("1"), + toAmount = BigDecimal("1"), + isAccountsMode = false, + ) + val dataState = SwapProcessDataState() + val uiState = baseStateHolder().copy( + swapButton = SwapButton( + walletInteractionIcon = null, + isEnabled = false, + mode = SwapButton.Mode.TRANSFER, + onClick = {}, + ), + ) + + val result = sut.updateTransferButtonEnableState( + dataState = dataState, + transferState = transferState, + actions = actions, + uiStateHolder = uiState, + feePaidCryptoCurrencyStatus = null, + fee = null, + isTangemPayWithdrawal = true, + ) + + assertThat(result.swapButton.isEnabled).isTrue() + assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER) + // fee is null → footer is omitted, but the button stays enabled because it is a Tangem Pay withdrawal + assertThat(result.transferFooter).isNull() + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN null fee and not tangem pay withdrawal WHEN updateTransferButtonEnableState THEN swap button stays disabled`() = + runTest { + val transferState = buildTransferState( + fromAmount = BigDecimal("1"), + toAmount = BigDecimal("1"), + isAccountsMode = false, + ) + val dataState = SwapProcessDataState() + val uiState = baseStateHolder().copy( + swapButton = SwapButton( + walletInteractionIcon = null, + isEnabled = false, + mode = SwapButton.Mode.TRANSFER, + onClick = {}, + ), + ) + + val result = sut.updateTransferButtonEnableState( + dataState = dataState, + transferState = transferState, + actions = actions, + uiStateHolder = uiState, + feePaidCryptoCurrencyStatus = null, + fee = null, + isTangemPayWithdrawal = false, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN transfer dataState WHEN createTangemPayWithdrawalSuccessState THEN feeless transfer success holder is built`() { + val sendingAmount = BigDecimal("1.5") + val transferState = buildTransferState( + fromAmount = sendingAmount, + toAmount = sendingAmount, + isAccountsMode = true, + ) + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + currentTransferState = transferState, + ) + val onExploreClick = {} + val appCurrency = transferState.appCurrency + val expectedFiat = stringReference( + fromCurrencyStatus.status.value.fiatRate!!.multiply(sendingAmount).format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + }, + ) + + val before = System.currentTimeMillis() + val result = sut.createTangemPayWithdrawalSuccessState( + uiState = baseStateHolder(), + dataState = dataState, + onExploreClick = onExploreClick, + ) + val after = System.currentTimeMillis() + + val success = requireNotNull(result.successState) + assertThat(success.isTransferMode).isTrue() + assertThat(success.shouldShowStatusButton).isFalse() + assertThat(success.fee).isNull() + assertThat(success.txUrl).isEmpty() + assertThat(success.providerName).isEqualTo(stringReference("")) + assertThat(success.providerType).isEqualTo(stringReference("")) + assertThat(success.providerIcon).isEmpty() + assertThat(success.rate).isEqualTo(TextReference.EMPTY) + assertThat(success.timestamp).isAtLeast(before) + assertThat(success.timestamp).isAtMost(after) + assertThat(success.fromTokenAmount).isEqualTo(stringReference(sendingAmount.toString())) + assertThat(success.toTokenAmount).isEqualTo(stringReference(sendingAmount.toString())) + assertThat(success.fromTokenFiatAmount).isEqualTo(expectedFiat) + assertThat(success.toTokenFiatAmount).isEqualTo(expectedFiat) + assertThat(success.fromTokenIconState).isEqualTo(fromIcon) + assertThat(success.toTokenIconState).isEqualTo(toIcon) + assertThat(success.onExploreButtonClick).isEqualTo(onExploreClick) + + val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio + val expectedIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon) + val expectedName = portfolioAccount.accountName.toUM().value + assertThat(success.fromTitle).isEqualTo( + AccountTitleUM.Account( + prefixText = resourceReference(R.string.swapping_from_account_title), + name = expectedName, + icon = expectedIcon, + ), + ) + assertThat(success.toTitle).isEqualTo( + AccountTitleUM.Account( + prefixText = resourceReference(R.string.swapping_to_account_title), + name = expectedName, + icon = expectedIcon, + ), + ) + } + private fun assertSharedCardShape( result: SwapStateHolder, transferState: SwapState.Transfer,