Updated on 2026-08-14
This commit is contained in:
parent
40f7df2c5d
commit
1ce3399a02
15 changed files with 890 additions and 65 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<PollingKey, Job>()
|
||||
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<UniversalError, WithdrawalResult> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<UniversalError, WithdrawalResult> {
|
||||
return repository.withdraw(
|
||||
userWallet = userWallet,
|
||||
cryptoAmount = cryptoAmount,
|
||||
receiverAddress = receiverCexAddress,
|
||||
cryptoCurrencyId = cryptoCurrencyId,
|
||||
exchangeData = exchangeData,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UniversalError, WithdrawalResult> {
|
||||
return repository.withdrawWithSwap(
|
||||
userWallet = userWallet,
|
||||
cryptoAmount = cryptoAmount,
|
||||
receiverAddress = receiverCexAddress,
|
||||
cryptoCurrencyId = cryptoCurrencyId,
|
||||
exchangeData = exchangeData,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Any>(userWalletId = any(), requestBlock = any())
|
||||
} coAnswers {
|
||||
val block = secondArg<suspend (String) -> ApiResponse<Any>>()
|
||||
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<WithdrawResponse>
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UniversalError, WithdrawalResult>
|
||||
|
||||
suspend fun withdraw(
|
||||
userWallet: UserWallet,
|
||||
receiverAddress: String,
|
||||
cryptoAmount: BigDecimal,
|
||||
cryptoCurrencyId: CryptoCurrency.RawID,
|
||||
): Either<UniversalError, WithdrawalResult>
|
||||
|
||||
suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean
|
||||
|
||||
suspend fun pollWithdrawOrdersIfNeeds(userWallet: UserWallet)
|
||||
|
|
|
|||
|
|
@ -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<UniversalError, WithdrawalResult>
|
||||
}
|
||||
|
|
@ -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<UniversalError, WithdrawalResult>
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<SendTransactionError, String>
|
||||
|
||||
suspend fun withdrawTangemPay(
|
||||
userWallet: UserWallet,
|
||||
cryptoAmount: BigDecimal,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
): Either<SendTransactionError, WithdrawalResult>
|
||||
}
|
||||
|
|
@ -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<SendTransactionError.DataError, String> {
|
||||
override suspend fun withdrawTangemPay(
|
||||
userWallet: UserWallet,
|
||||
cryptoAmount: BigDecimal,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
): Either<SendTransactionError, WithdrawalResult> {
|
||||
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<SendTransactionError.DataError, Nothing> {
|
||||
return SendTransactionError.DataError(message).left()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<NotificationUM>, fee: Fee?): Boolean {
|
||||
return fee != null && notifications.none { notification ->
|
||||
private fun getTransferButtonEnabled(
|
||||
notifications: ImmutableList<NotificationUM>,
|
||||
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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue