Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 17:00:20 +04:00
parent 4dec0b44cd
commit f19c2a9fd5
14 changed files with 488 additions and 9 deletions

View file

@ -10,9 +10,9 @@ import com.tangem.tap.data.converter.PendingOfframpEntryConverter
import com.tangem.tap.data.model.PendingOfframpEntry
import com.tangem.tap.network.exchangeServices.SellService
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Default implementation of [OfframpRepository].
@ -27,7 +27,7 @@ internal class DefaultOfframpRepository(
private val dispatchers: CoroutineDispatcherProvider,
) : OfframpRepository {
private val pendingOfframpConverter = PendingOfframpEntryConverter()
private val converter = PendingOfframpEntryConverter()
override fun getOfframpUrl(
cryptoCurrency: CryptoCurrency,
@ -71,19 +71,21 @@ internal class DefaultOfframpRepository(
entry.requestId == requestId &&
entry.userWalletId == userWalletId.stringValue &&
entry.currencyId == currencyId &&
now - entry.createdAt < EXPIRY_MS
!entry.isExpired(now)
}
// Remove only the fully-matched record (single-use); always prune expired ones. A request_id that
// matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it.
stored.filter { it != matched }.filterNotExpired(now)
}
matched?.let(pendingOfframpConverter::convert)
matched?.let(converter::convert)
}
override suspend fun getAllStoredOfframps(): List<PendingOfframp> = withContext(dispatchers.io) {
pendingOfframpStore.data.first().map(converter::convert)
}
private fun List<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> =
filter { now - it.createdAt < EXPIRY_MS }
filterNot { it.isExpired(now) }
private companion object {
val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1)
}
private fun PendingOfframpEntry.isExpired(now: Long): Boolean = converter.convert(this).isExpired(now)
}

View file

@ -236,4 +236,44 @@ internal class DefaultOfframpRepositoryTest {
// Assert
assertThat(pending).isNull()
}
@Test
fun `GIVEN registered pending offramps WHEN getAllStoredOfframps THEN returns them without consuming`() = runTest {
// Arrange
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
// Act
val stored = repository.getAllStoredOfframps()
// ...the record must survive the read so it can still be consumed afterwards
val consumed = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
// Assert
assertThat(stored).hasSize(1)
assertThat(stored.single().requestId).isEqualTo(storedRequestId)
assertThat(stored.single().userWalletId).isEqualTo(userWalletId)
assertThat(stored.single().currencyId).isEqualTo(currencyId)
assertThat(consumed).isNotNull()
}
@Test
fun `GIVEN expired pending offramp WHEN getAllStoredOfframps THEN it is still returned and flagged expired`() =
runTest {
// Arrange — seed a record created 2 hours ago (past the 1h expiry)
val now = System.currentTimeMillis()
pendingStoreState.value = listOf(
PendingOfframpEntry(
requestId = "expired-id",
userWalletId = userWalletId.stringValue,
currencyId = currencyId,
createdAt = now - TimeUnit.HOURS.toMillis(2),
),
)
// Act
val stored = repository.getAllStoredOfframps()
// Assert
assertThat(stored).hasSize(1)
assertThat(stored.single().isExpired(now)).isTrue()
}
}