Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-22 11:42:23 +04:00
parent d594af91fc
commit c3f5ea4284
10 changed files with 496 additions and 43 deletions

View file

@ -1,7 +1,6 @@
package com.tangem.domain.yield.supply
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
@ -84,13 +83,10 @@ interface YieldSupplyRepository {
* Retrieve the hashes of pending (unconfirmed) transactions for the given wallet and currency.
*
* @param userWalletId the wallet to query
* @param cryptoCurrencyStatus the currency status containing network information
* @param cryptoCurrency the currency containing network information
* @return list of transaction hashes that are still unconfirmed, or an empty list if none exist
*/
suspend fun getPendingTxHashes(
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): List<String>
suspend fun getPendingTxHashes(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): List<String>
/**
* Get the last saved userinitiated yield protocol action for the given wallet and currency, if any.

View file

@ -20,7 +20,7 @@ class YieldSupplyEnterStatusUseCase(
cryptoCurrencyStatus.currency,
)
val pendingTxHashes = yieldSupplyRepository
.getPendingTxHashes(userWalletId, cryptoCurrencyStatus)
.getPendingTxHashes(userWalletId, cryptoCurrencyStatus.currency)
.toSet()
val hasPendingTx = status?.txIds?.any { it in pendingTxHashes } == true

View file

@ -0,0 +1,147 @@
package com.tangem.domain.yield.supply.usecase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.yield.supply.YieldSupplyRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
/**
* Periodically checks pending Yield Supply transaction hashes for a given set of wallet/currency pairs.
*
* - Call [addPending] right after sending a transaction to start tracking its hashes.
* - The checker runs every [CHECK_INTERVAL_MS] and queries repository for the current pending tx hashes.
* - If none of the stored hashes are pending anymore for a tracked entry, it triggers a single-network status refresh
* via [singleNetworkStatusFetcher] and removes the entry from tracking.
* - The periodic job stops automatically when there is nothing left to track.
*/
class YieldSupplyPendingTracker(
private val yieldSupplyRepository: YieldSupplyRepository,
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
private val coroutineScope: CoroutineScope,
) {
private data class TrackedKey(
val userWalletId: UserWalletId,
val cryptoCurrencyId: CryptoCurrency.ID,
)
private data class TrackedEntry(
val cryptoCurrency: CryptoCurrency,
val txIds: Set<String>,
val attempts: Int = 0,
)
private val trackedEntries = ConcurrentHashMap<TrackedKey, TrackedEntry>()
private val mutex = Mutex()
private var checkingJob: Job? = null
/**
* Add tx ids to track for a wallet/currency pair. Starts periodic checking if needed.
*/
suspend fun addPending(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, txIds: List<String>) {
val key = TrackedKey(userWalletId, cryptoCurrency.id)
trackedEntries.compute(key) { _, existing ->
if (existing == null) {
TrackedEntry(
cryptoCurrency = cryptoCurrency,
txIds = txIds.toSet(),
)
} else {
existing.copy(txIds = existing.txIds + txIds)
}
}
startAutomaticCheckingIfNeeded()
}
private suspend fun startAutomaticCheckingIfNeeded() {
mutex.withLock {
if (checkingJob?.isActive == true) return
checkingJob = coroutineScope.launch {
while (isActive) {
try {
checkAllTracked()
} catch (ex: Exception) {
Timber.e(ex)
}
delay(CHECK_INTERVAL_MS)
}
}
}
}
private suspend fun stopAutomaticCheckingIfEmpty() {
if (trackedEntries.isEmpty()) {
mutex.withLock {
if (trackedEntries.isEmpty()) {
checkingJob?.cancel()
checkingJob = null
}
}
}
}
private suspend fun checkAllTracked() {
val keysSnapshot = trackedEntries.keys().toList()
if (keysSnapshot.isEmpty()) {
stopAutomaticCheckingIfEmpty()
return
}
val networksToFetch = mutableSetOf<SingleNetworkStatusFetcher.Params>()
for (key in keysSnapshot) {
val entry = trackedEntries[key] ?: continue
if (entry.txIds.isEmpty()) {
trackedEntries.remove(key)
continue
}
val pendingTxHashes =
yieldSupplyRepository.getPendingTxHashes(
userWalletId = key.userWalletId,
cryptoCurrency = entry.cryptoCurrency,
)
.toSet()
val hasStillPending = entry.txIds.any { it in pendingTxHashes }
if (hasStillPending) {
trackedEntries.computeIfPresent(key) { _, current ->
val newAttempts = current.attempts + 1
if (newAttempts >= MAX_ATTEMPTS) null else current.copy(attempts = newAttempts)
}
} else {
trackedEntries.remove(key)
}
networksToFetch.add(
SingleNetworkStatusFetcher.Params(
userWalletId = key.userWalletId,
network = entry.cryptoCurrency.network,
),
)
}
for (params in networksToFetch) {
singleNetworkStatusFetcher(params)
}
stopAutomaticCheckingIfEmpty()
}
private companion object {
private const val CHECK_INTERVAL_MS = 10_000L
private const val MAX_ATTEMPTS = 6
}
}

View file

@ -45,7 +45,7 @@ class YieldSupplyEnterStatusUseCaseTest {
yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token)
} returns status
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus)
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency)
} returns listOf(pendingTxHash)
val result = useCase(userWalletId, cryptoStatus)
@ -65,7 +65,7 @@ class YieldSupplyEnterStatusUseCaseTest {
yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token)
} returns status
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus)
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency)
} returns listOf("0xdifferent")
coEvery {
yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null)
@ -88,7 +88,7 @@ class YieldSupplyEnterStatusUseCaseTest {
yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token)
} returns null
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus)
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency)
} returns emptyList()
coEvery {
yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null)
@ -111,7 +111,7 @@ class YieldSupplyEnterStatusUseCaseTest {
yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token)
} returns status
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus)
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency)
} returns emptyList()
coEvery {
yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null)
@ -153,7 +153,7 @@ class YieldSupplyEnterStatusUseCaseTest {
yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token)
} returns status
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus)
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency)
} returns listOf(pendingTxHash)
val result = useCase(userWalletId, cryptoStatus)
@ -174,7 +174,7 @@ class YieldSupplyEnterStatusUseCaseTest {
yieldSupplyRepository.getTokenProtocolPendingStatus(userWalletId, token)
} returns status
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus)
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency)
} returns listOf(matchingTxHash)
val result = useCase(userWalletId, cryptoStatus)

View file

@ -0,0 +1,308 @@
package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.yield.supply.YieldSupplyRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class YieldSupplyPendingTrackerTest {
private val yieldSupplyRepository: YieldSupplyRepository = mockk()
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk()
private lateinit var testScope: TestScope
private lateinit var useCase: YieldSupplyPendingTracker
private val userWalletId = UserWalletId("00")
@BeforeEach
fun setUp() {
testScope = TestScope(StandardTestDispatcher())
useCase = YieldSupplyPendingTracker(
yieldSupplyRepository = yieldSupplyRepository,
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
coroutineScope = testScope,
)
}
@Test
fun `GIVEN new tx ids WHEN addPending THEN stores entry for tracking`() = runTest {
val token = createToken()
val txIds = listOf("0xabc123", "0xdef456")
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
} returns txIds
coEvery {
singleNetworkStatusFetcher.invoke(any())
} returns Either.Right(Unit)
useCase.addPending(userWalletId, token, txIds)
testScope.advanceTimeBy(10_001L)
coVerify {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
}
}
@Test
fun `GIVEN existing entry WHEN addPending THEN merges tx ids`() = runTest {
val token = createToken()
val firstTxIds = listOf("0xfirst")
val secondTxIds = listOf("0xsecond")
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
} returns firstTxIds + secondTxIds
coEvery {
singleNetworkStatusFetcher.invoke(any())
} returns Either.Right(Unit)
useCase.addPending(userWalletId, token, firstTxIds)
useCase.addPending(userWalletId, token, secondTxIds)
testScope.advanceTimeBy(10_001L)
coVerify {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
}
}
@Test
fun `GIVEN tx still pending WHEN addPending THEN triggers network status refresh`() = runTest {
val token = createToken()
val txIds = listOf("0xpending")
val paramsSlot = slot<SingleNetworkStatusFetcher.Params>()
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
} returns txIds
coEvery {
singleNetworkStatusFetcher.invoke(capture(paramsSlot))
} returns Either.Right(Unit)
useCase.addPending(userWalletId, token, txIds)
testScope.advanceTimeBy(10_001L)
coVerify {
singleNetworkStatusFetcher.invoke(any())
}
assertThat(paramsSlot.captured.userWalletId).isEqualTo(userWalletId)
assertThat(paramsSlot.captured.network).isEqualTo(token.network)
}
@Test
fun `GIVEN tx no longer pending WHEN addPending THEN removes entry from tracking and refreshes network`() = runTest {
val token = createToken()
val txIds = listOf("0xconfirmed")
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
} returns emptyList()
coEvery {
singleNetworkStatusFetcher.invoke(any())
} returns Either.Right(Unit)
useCase.addPending(userWalletId, token, txIds)
testScope.advanceTimeBy(10_001L)
coVerify(exactly = 1) {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
}
coVerify(exactly = 1) {
singleNetworkStatusFetcher.invoke(any())
}
}
@Test
fun `GIVEN max attempts reached WHEN addPending THEN stops tracking entry`() = runTest {
val token = createToken()
val txIds = listOf("0xstuck")
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
} returns txIds
coEvery {
singleNetworkStatusFetcher.invoke(any())
} returns Either.Right(Unit)
useCase.addPending(userWalletId, token, txIds)
testScope.advanceTimeBy(10_001L * 6)
coVerify(exactly = 6) {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
}
testScope.advanceTimeBy(10_001L)
coVerify(exactly = 6) {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
}
}
@Test
fun `GIVEN multiple wallets WHEN addPending THEN tracks each separately`() = runTest {
val token = createToken()
val userWalletId1 = UserWalletId("00")
val userWalletId2 = UserWalletId("01")
val txIds1 = listOf("0xtx1")
val txIds2 = listOf("0xtx2")
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId1, token)
} returns txIds1
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId2, token)
} returns txIds2
coEvery {
singleNetworkStatusFetcher.invoke(any())
} returns Either.Right(Unit)
useCase.addPending(userWalletId1, token, txIds1)
useCase.addPending(userWalletId2, token, txIds2)
testScope.advanceTimeBy(10_001L)
coVerify {
yieldSupplyRepository.getPendingTxHashes(userWalletId1, token)
yieldSupplyRepository.getPendingTxHashes(userWalletId2, token)
}
}
@Test
fun `GIVEN multiple wallets with same network WHEN checkAllTracked THEN fetches network status once per unique pair`() = runTest {
val token = createToken()
val userWalletId1 = UserWalletId("00")
val userWalletId2 = UserWalletId("01")
val txIds1 = listOf("0xtx1")
val txIds2 = listOf("0xtx2")
val capturedParams = mutableListOf<SingleNetworkStatusFetcher.Params>()
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId1, token)
} returns txIds1
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId2, token)
} returns txIds2
coEvery {
singleNetworkStatusFetcher.invoke(capture(capturedParams))
} returns Either.Right(Unit)
useCase.addPending(userWalletId1, token, txIds1)
useCase.addPending(userWalletId2, token, txIds2)
testScope.advanceTimeBy(1000)
assertThat(capturedParams).hasSize(2)
assertThat(capturedParams.map { it.userWalletId }.toSet())
.containsExactly(userWalletId1, userWalletId2)
assertThat(capturedParams.map { it.network }.toSet())
.containsExactly(token.network)
}
@Test
fun `GIVEN same wallet with different currencies WHEN addPending THEN tracks each currency separately`() = runTest {
val token1 = createToken(networkId = "ethereum", contractAddress = "0xToken1")
val token2 = createToken(networkId = "polygon", contractAddress = "0xToken2")
val txIds1 = listOf("0xtx1")
val txIds2 = listOf("0xtx2")
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token1)
} returns txIds1
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token2)
} returns txIds2
coEvery {
singleNetworkStatusFetcher.invoke(any())
} returns Either.Right(Unit)
useCase.addPending(userWalletId, token1, txIds1)
useCase.addPending(userWalletId, token2, txIds2)
testScope.advanceTimeBy(10_001L)
coVerify {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token1)
yieldSupplyRepository.getPendingTxHashes(userWalletId, token2)
}
}
@Test
fun `GIVEN partial tx match WHEN addPending THEN still triggers refresh`() = runTest {
val token = createToken()
val trackedTxIds = listOf("0xtracked1", "0xtracked2")
val pendingTxIds = listOf("0xtracked1")
val paramsSlot = slot<SingleNetworkStatusFetcher.Params>()
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, token)
} returns pendingTxIds
coEvery {
singleNetworkStatusFetcher.invoke(capture(paramsSlot))
} returns Either.Right(Unit)
useCase.addPending(userWalletId, token, trackedTxIds)
testScope.advanceTimeBy(10_001L)
coVerify {
singleNetworkStatusFetcher.invoke(any())
}
assertThat(paramsSlot.captured.userWalletId).isEqualTo(userWalletId)
}
private fun createToken(
networkId: String = "ethereum",
contractAddress: String = "0xToken",
): CryptoCurrency.Token {
val derivationPath = Network.DerivationPath.None
val network = Network(
id = Network.ID(value = networkId, derivationPath = derivationPath),
backendId = networkId,
name = networkId,
currencySymbol = networkId.take(3).uppercase(),
derivationPath = derivationPath,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(networkId),
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
),
network = network,
name = "TEST_TOKEN",
symbol = "TTK",
decimals = 18,
iconUrl = null,
isCustom = false,
contractAddress = contractAddress,
)
}
}