Updated on 2026-08-14
This commit is contained in:
parent
7b4593acee
commit
223051955f
22 changed files with 606 additions and 95 deletions
|
|
@ -42,6 +42,7 @@ dependencies {
|
|||
/** Domain */
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.offramp)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.tokens)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCrypto
|
|||
import com.tangem.domain.account.supplier.SingleAccountListSupplier
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.offramp.repository.OfframpRepository
|
||||
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
|
||||
import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -20,13 +21,14 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.launch
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
|
||||
@Suppress("ComplexCondition")
|
||||
@Suppress("ComplexCondition", "LongParameterList")
|
||||
internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
|
||||
@Assisted scope: CoroutineScope,
|
||||
@Assisted queryParams: Map<String, String>,
|
||||
appRouter: AppRouter,
|
||||
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
|
||||
private val singleAccountListSupplier: SingleAccountListSupplier,
|
||||
private val offrampRepository: OfframpRepository,
|
||||
) : SellRedirectDeepLinkHandler {
|
||||
|
||||
init {
|
||||
|
|
@ -35,6 +37,7 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
|
|||
val amount = queryParams[AMOUNT_KEY]
|
||||
val destinationAddress = queryParams[DESTINATION_ADDRESS_KEY]
|
||||
val memo = queryParams[MEMO_KEY]
|
||||
val requestId = queryParams[REQUEST_ID_KEY]
|
||||
|
||||
// It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
|
||||
getSelectedWalletSyncUseCase()
|
||||
|
|
@ -44,20 +47,30 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
|
|||
},
|
||||
ifRight = { userWallet ->
|
||||
if (currencyId.isNullOrEmpty() || transactionId.isNullOrEmpty() ||
|
||||
amount.isNullOrEmpty() || destinationAddress.isNullOrEmpty()
|
||||
amount.isNullOrEmpty() || destinationAddress.isNullOrEmpty() ||
|
||||
requestId.isNullOrEmpty()
|
||||
) {
|
||||
TangemLogger.e(
|
||||
"""
|
||||
Invalid parameters for SELL deeplink
|
||||
|- Params: $queryParams
|
||||
""".trimIndent(),
|
||||
)
|
||||
// Do not log the params: they contain the deposit address and request_id.
|
||||
TangemLogger.e("Invalid parameters for SELL deeplink")
|
||||
return@fold
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
// Only trust the redirect if it carries a request_id we issued for a sell this
|
||||
// app actually started (single-use, bound to the wallet + currency). Otherwise an external
|
||||
// deeplink could inject a locked attacker recipient/amount into the Send confirm screen.
|
||||
val pendingOfframp = offrampRepository.consumePendingOfframp(
|
||||
requestId = requestId,
|
||||
userWalletId = userWallet.walletId,
|
||||
currencyId = currencyId,
|
||||
)
|
||||
if (pendingOfframp == null) {
|
||||
TangemLogger.e("Rejected SELL deeplink: no matching app-initiated sell")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val cryptoCurrency = getCryptoCurrency(userWallet.walletId, currencyId).getOrElse {
|
||||
TangemLogger.e("Error on getting cryptoCurrency: $currencyId")
|
||||
TangemLogger.e("Error on getting cryptoCurrency for SELL deeplink")
|
||||
return@launch
|
||||
}
|
||||
// Convert using universal parser to account for regional separators
|
||||
|
|
@ -100,5 +113,6 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
|
|||
const val AMOUNT_KEY = "baseCurrencyAmount"
|
||||
const val DESTINATION_ADDRESS_KEY = "depositWalletAddress"
|
||||
const val MEMO_KEY = "depositWalletAddressTag"
|
||||
const val REQUEST_ID_KEY = "request_id"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package com.tangem.features.send.deeplink
|
||||
|
||||
import arrow.core.right
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.domain.account.supplier.SingleAccountListSupplier
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.offramp.model.PendingOfframp
|
||||
import com.tangem.domain.offramp.repository.OfframpRepository
|
||||
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class DefaultSellRedirectDeepLinkHandlerTest {
|
||||
|
||||
private val appRouter: AppRouter = mockk(relaxed = true)
|
||||
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase = mockk()
|
||||
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
|
||||
private val offrampRepository: OfframpRepository = mockk()
|
||||
|
||||
private val userWalletId = UserWalletId("0011223344556677")
|
||||
private val currencyId = "bitcoin"
|
||||
private val requestId = "request-id-001"
|
||||
private val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(appRouter, getSelectedWalletSyncUseCase, singleAccountListSupplier, offrampRepository)
|
||||
every { getSelectedWalletSyncUseCase() } returns userWallet.right()
|
||||
// Returning null here means the (legitimate) currency lookup yields nothing, so a passed gate stops before
|
||||
// navigation. We assert the gate via whether the currency lookup is reached at all.
|
||||
coEvery { singleAccountListSupplier.getSyncOrNull(any<UserWalletId>()) } returns null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN matching pending offramp WHEN deeplink handled THEN request passes the gate`() = runTest {
|
||||
coEvery { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) } returns pendingOfframp()
|
||||
|
||||
createHandler(validParams())
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) }
|
||||
coVerify(exactly = 1) { singleAccountListSupplier.getSyncOrNull(userWalletId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no request_id WHEN deeplink handled THEN rejected without touching the store`() = runTest {
|
||||
createHandler(validParams() - REQUEST_ID_KEY)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { offrampRepository.consumePendingOfframp(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { singleAccountListSupplier.getSyncOrNull(any<UserWalletId>()) }
|
||||
verify(exactly = 0) { appRouter.push(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no matching pending offramp WHEN deeplink handled THEN rejected`() = runTest {
|
||||
coEvery { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) } returns null
|
||||
|
||||
createHandler(validParams())
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { singleAccountListSupplier.getSyncOrNull(any<UserWalletId>()) }
|
||||
verify(exactly = 0) { appRouter.push(any()) }
|
||||
}
|
||||
|
||||
private fun TestScope.createHandler(queryParams: Map<String, String>) = DefaultSellRedirectDeepLinkHandler(
|
||||
scope = this,
|
||||
queryParams = queryParams,
|
||||
appRouter = appRouter,
|
||||
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
|
||||
singleAccountListSupplier = singleAccountListSupplier,
|
||||
offrampRepository = offrampRepository,
|
||||
)
|
||||
|
||||
private fun pendingOfframp() = PendingOfframp(
|
||||
requestId = requestId,
|
||||
userWalletId = userWalletId,
|
||||
currencyId = currencyId,
|
||||
createdAt = 0L,
|
||||
)
|
||||
|
||||
private fun validParams() = mapOf(
|
||||
"currency_id" to currencyId,
|
||||
"transactionId" to "tx-001",
|
||||
"baseCurrencyAmount" to "1.5",
|
||||
"depositWalletAddress" to "depositAddress",
|
||||
REQUEST_ID_KEY to requestId,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val REQUEST_ID_KEY = "request_id"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue