Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 15:53:16 +04:00
parent 7c1bfc2c7c
commit f35975398d
5 changed files with 60 additions and 37 deletions

View file

@ -59,7 +59,7 @@ internal class DefaultOfframpRepository(
requestId requestId
} }
override suspend fun consumePendingOfframp( override suspend fun resolvePendingOfframp(
requestId: String, requestId: String,
userWalletId: UserWalletId, userWalletId: UserWalletId,
currencyId: String, currencyId: String,
@ -73,15 +73,17 @@ internal class DefaultOfframpRepository(
entry.currencyId == currencyId && entry.currencyId == currencyId &&
now - entry.createdAt < EXPIRY_MS now - entry.createdAt < EXPIRY_MS
} }
// Remove only the fully-matched record (single-use); always prune expired ones. A request_id that // Keep the matched record so the same redirect can be followed again until it expires; only prune the
// matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it. // expired ones. The record is dropped naturally once it ages past EXPIRY_MS.
stored.filter { it != matched }.filterNotExpired(now) stored.filterNotExpired(now)
} }
matched?.let(pendingOfframpConverter::convert) matched?.let(pendingOfframpConverter::convert)
} }
// Returns the same instance when nothing is expired, so DataStore.updateData sees an unchanged value and skips
// both the extra allocation and the write.
private fun List<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> = private fun List<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> =
filter { now - it.createdAt < EXPIRY_MS } if (none { now - it.createdAt >= EXPIRY_MS }) this else filter { now - it.createdAt < EXPIRY_MS }
private companion object { private companion object {
val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1) val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1)

View file

@ -156,13 +156,13 @@ internal class DefaultOfframpRepositoryTest {
} }
@Test @Test
fun `GIVEN registered pending offramp WHEN consume with matching wallet and currency THEN returns record`() = fun `GIVEN registered pending offramp WHEN resolve with matching wallet and currency THEN returns record`() =
runTest { runTest {
// Arrange // Arrange
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
// Act // Act
val pending = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) val pending = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId)
// Assert // Assert
assertThat(pending).isNotNull() assertThat(pending).isNotNull()
@ -172,35 +172,36 @@ internal class DefaultOfframpRepositoryTest {
} }
@Test @Test
fun `GIVEN unknown request id WHEN consume THEN returns null`() = runTest { fun `GIVEN unknown request id WHEN resolve THEN returns null`() = runTest {
repository.registerPendingOfframp(userWalletId, currencyId) repository.registerPendingOfframp(userWalletId, currencyId)
assertThat(repository.consumePendingOfframp("unknown", userWalletId, currencyId)).isNull() assertThat(repository.resolvePendingOfframp("unknown", userWalletId, currencyId)).isNull()
} }
@Test @Test
fun `GIVEN already consumed pending offramp WHEN consume again THEN returns null`() = runTest { fun `GIVEN already resolved pending offramp WHEN resolve again THEN still returns record`() = runTest {
// Arrange // Arrange
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
// Act // Act — resolving does NOT consume the record; the same redirect may be followed again until it expires
val first = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) val first = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId)
val second = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) val second = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId)
// Assert // Assert
assertThat(first).isNotNull() assertThat(first).isNotNull()
assertThat(second).isNull() assertThat(second).isNotNull()
assertThat(second?.requestId).isEqualTo(storedRequestId)
} }
@Test @Test
fun `GIVEN mismatched currency WHEN consume THEN returns null and does NOT burn the pending sell`() = runTest { fun `GIVEN mismatched currency WHEN resolve THEN returns null and leaves the pending sell resolvable`() = runTest {
// Arrange // Arrange
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
// Act — a tampered redirect with the right request_id but a wrong currency must not consume the token // Act — a tampered redirect with the right request_id but a wrong currency must not match
val mismatched = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId = "ethereum") val mismatched = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId = "ethereum")
// ...so the legitimate redirect can still succeed afterwards // ...and the legitimate redirect must still resolve afterwards
val legitimate = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) val legitimate = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId)
// Assert // Assert
assertThat(mismatched).isNull() assertThat(mismatched).isNull()
@ -209,16 +210,16 @@ internal class DefaultOfframpRepositoryTest {
} }
@Test @Test
fun `GIVEN mismatched wallet WHEN consume THEN returns null`() = runTest { fun `GIVEN mismatched wallet WHEN resolve THEN returns null`() = runTest {
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
val result = repository.consumePendingOfframp(storedRequestId, UserWalletId("ffeeddccbbaa9988"), currencyId) val result = repository.resolvePendingOfframp(storedRequestId, UserWalletId("ffeeddccbbaa9988"), currencyId)
assertThat(result).isNull() assertThat(result).isNull()
} }
@Test @Test
fun `GIVEN expired pending offramp WHEN consume THEN returns null`() = runTest { fun `GIVEN expired pending offramp WHEN resolve THEN returns null`() = runTest {
// Arrange — seed a record created 2 hours ago (past the 1h expiry) // Arrange — seed a record created 2 hours ago (past the 1h expiry)
val expiredId = "expired-id" val expiredId = "expired-id"
pendingStoreState.value = listOf( pendingStoreState.value = listOf(
@ -231,7 +232,7 @@ internal class DefaultOfframpRepositoryTest {
) )
// Act // Act
val pending = repository.consumePendingOfframp(expiredId, userWalletId, currencyId) val pending = repository.resolvePendingOfframp(expiredId, userWalletId, currencyId)
// Assert // Assert
assertThat(pending).isNull() assertThat(pending).isNull()

View file

@ -15,8 +15,8 @@ interface OfframpRepository {
* @param cryptoCurrency crypto currency to sell * @param cryptoCurrency crypto currency to sell
* @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR") * @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR")
* @param walletAddress wallet address for the refund * @param walletAddress wallet address for the refund
* @param requestId single-use nonce embedded into the provider redirect URL to authenticate the * @param requestId nonce embedded into the provider redirect URL to authenticate the returning
* returning `redirect_sell` deeplink * `redirect_sell` deeplink
* @return URL for offramp service or null if not available * @return URL for offramp service or null if not available
*/ */
fun getOfframpUrl( fun getOfframpUrl(
@ -28,16 +28,19 @@ interface OfframpRepository {
/** /**
* Registers a new app-initiated sell for [userWalletId] / [currencyId], prunes expired records, and returns a * Registers a new app-initiated sell for [userWalletId] / [currencyId], prunes expired records, and returns a
* fresh single-use `request_id` to embed in the provider redirect URL. * fresh `request_id` to embed in the provider redirect URL. The record stays valid until it expires.
*/ */
suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String
/** /**
* Returns and removes (single-use) the pending sell matching [requestId] only when it is not expired and was * Returns the pending sell matching [requestId] when it is not expired and was registered for the same
* registered for the same [userWalletId] and [currencyId]. Returns `null` otherwise, leaving a non-matching * [userWalletId] and [currencyId]; returns `null` otherwise.
* record untouched so a tampered redirect cannot burn a legitimate pending sell. *
* The matching record is **not** removed it remains valid until it expires, so the same `redirect_sell`
* deeplink can be followed repeatedly within that window (e.g. the user re-opens it). Expired records are pruned
* as a side effect.
*/ */
suspend fun consumePendingOfframp( suspend fun resolvePendingOfframp(
requestId: String, requestId: String,
userWalletId: UserWalletId, userWalletId: UserWalletId,
currencyId: String, currencyId: String,

View file

@ -57,9 +57,11 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
scope.launch { scope.launch {
// Only trust the redirect if it carries a request_id we issued for a sell this // 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 // app actually started (bound to the wallet + currency, valid until it expires). Otherwise an
// deeplink could inject a locked attacker recipient/amount into the Send confirm screen. // external deeplink could inject a locked attacker recipient/amount into the Send confirm
val pendingOfframp = offrampRepository.consumePendingOfframp( // screen. The record is kept until expiry so the user can re-open the redirect within that
// window.
val pendingOfframp = offrampRepository.resolvePendingOfframp(
requestId = requestId, requestId = requestId,
userWalletId = userWallet.walletId, userWalletId = userWallet.walletId,
currencyId = currencyId, currencyId = currencyId,

View file

@ -43,28 +43,43 @@ internal class DefaultSellRedirectDeepLinkHandlerTest {
@Test @Test
fun `GIVEN matching pending offramp WHEN deeplink handled THEN request passes the gate`() = runTest { fun `GIVEN matching pending offramp WHEN deeplink handled THEN request passes the gate`() = runTest {
coEvery { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) } returns pendingOfframp() coEvery { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) } returns pendingOfframp()
createHandler(validParams()) createHandler(validParams())
advanceUntilIdle() advanceUntilIdle()
coVerify(exactly = 1) { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) } coVerify(exactly = 1) { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) }
coVerify(exactly = 1) { singleAccountListSupplier.getSyncOrNull(userWalletId) } coVerify(exactly = 1) { singleAccountListSupplier.getSyncOrNull(userWalletId) }
} }
@Test
fun `GIVEN matching pending offramp WHEN deeplink handled twice THEN gate passes both times`() = runTest {
// The pending sell is not single-use: resolving it does not remove it, so re-opening the same deeplink must
// pass the gate again. Guards against reintroducing single-use behavior in the handler.
coEvery { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) } returns pendingOfframp()
createHandler(validParams())
advanceUntilIdle()
createHandler(validParams())
advanceUntilIdle()
coVerify(exactly = 2) { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) }
coVerify(exactly = 2) { singleAccountListSupplier.getSyncOrNull(userWalletId) }
}
@Test @Test
fun `GIVEN no request_id WHEN deeplink handled THEN rejected without touching the store`() = runTest { fun `GIVEN no request_id WHEN deeplink handled THEN rejected without touching the store`() = runTest {
createHandler(validParams() - REQUEST_ID_KEY) createHandler(validParams() - REQUEST_ID_KEY)
advanceUntilIdle() advanceUntilIdle()
coVerify(exactly = 0) { offrampRepository.consumePendingOfframp(any(), any(), any()) } coVerify(exactly = 0) { offrampRepository.resolvePendingOfframp(any(), any(), any()) }
coVerify(exactly = 0) { singleAccountListSupplier.getSyncOrNull(any<UserWalletId>()) } coVerify(exactly = 0) { singleAccountListSupplier.getSyncOrNull(any<UserWalletId>()) }
verify(exactly = 0) { appRouter.push(any()) } verify(exactly = 0) { appRouter.push(any()) }
} }
@Test @Test
fun `GIVEN no matching pending offramp WHEN deeplink handled THEN rejected`() = runTest { fun `GIVEN no matching pending offramp WHEN deeplink handled THEN rejected`() = runTest {
coEvery { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) } returns null coEvery { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) } returns null
createHandler(validParams()) createHandler(validParams())
advanceUntilIdle() advanceUntilIdle()