diff --git a/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt b/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt index 7050229bd8..89f3a83804 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt @@ -59,7 +59,7 @@ internal class DefaultOfframpRepository( requestId } - override suspend fun consumePendingOfframp( + override suspend fun resolvePendingOfframp( requestId: String, userWalletId: UserWalletId, currencyId: String, @@ -73,15 +73,17 @@ internal class DefaultOfframpRepository( entry.currencyId == currencyId && now - entry.createdAt < EXPIRY_MS } - // 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) + // Keep the matched record so the same redirect can be followed again until it expires; only prune the + // expired ones. The record is dropped naturally once it ages past EXPIRY_MS. + stored.filterNotExpired(now) } 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.filterNotExpired(now: Long): List = - filter { now - it.createdAt < EXPIRY_MS } + if (none { now - it.createdAt >= EXPIRY_MS }) this else filter { now - it.createdAt < EXPIRY_MS } private companion object { val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1) diff --git a/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt b/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt index 7c7f6f4c98..d8c15954ad 100644 --- a/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt @@ -156,13 +156,13 @@ internal class DefaultOfframpRepositoryTest { } @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 { // Arrange val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) // Act - val pending = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) + val pending = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId) // Assert assertThat(pending).isNotNull() @@ -172,35 +172,36 @@ internal class DefaultOfframpRepositoryTest { } @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) - assertThat(repository.consumePendingOfframp("unknown", userWalletId, currencyId)).isNull() + assertThat(repository.resolvePendingOfframp("unknown", userWalletId, currencyId)).isNull() } @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 val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) - // Act - val first = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) - val second = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) + // Act — resolving does NOT consume the record; the same redirect may be followed again until it expires + val first = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId) + val second = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId) // Assert assertThat(first).isNotNull() - assertThat(second).isNull() + assertThat(second).isNotNull() + assertThat(second?.requestId).isEqualTo(storedRequestId) } @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 val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) - // Act — a tampered redirect with the right request_id but a wrong currency must not consume the token - val mismatched = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId = "ethereum") - // ...so the legitimate redirect can still succeed afterwards - val legitimate = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) + // Act — a tampered redirect with the right request_id but a wrong currency must not match + val mismatched = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId = "ethereum") + // ...and the legitimate redirect must still resolve afterwards + val legitimate = repository.resolvePendingOfframp(storedRequestId, userWalletId, currencyId) // Assert assertThat(mismatched).isNull() @@ -209,16 +210,16 @@ internal class DefaultOfframpRepositoryTest { } @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 result = repository.consumePendingOfframp(storedRequestId, UserWalletId("ffeeddccbbaa9988"), currencyId) + val result = repository.resolvePendingOfframp(storedRequestId, UserWalletId("ffeeddccbbaa9988"), currencyId) assertThat(result).isNull() } @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) val expiredId = "expired-id" pendingStoreState.value = listOf( @@ -231,7 +232,7 @@ internal class DefaultOfframpRepositoryTest { ) // Act - val pending = repository.consumePendingOfframp(expiredId, userWalletId, currencyId) + val pending = repository.resolvePendingOfframp(expiredId, userWalletId, currencyId) // Assert assertThat(pending).isNull() diff --git a/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt b/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt index b93b8e77c0..9aad84ed19 100644 --- a/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt +++ b/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt @@ -15,8 +15,8 @@ interface OfframpRepository { * @param cryptoCurrency crypto currency to sell * @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR") * @param walletAddress wallet address for the refund - * @param requestId single-use nonce embedded into the provider redirect URL to authenticate the - * returning `redirect_sell` deeplink + * @param requestId nonce embedded into the provider redirect URL to authenticate the returning + * `redirect_sell` deeplink * @return URL for offramp service or null if not available */ fun getOfframpUrl( @@ -28,16 +28,19 @@ interface OfframpRepository { /** * 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 /** - * Returns and removes (single-use) the pending sell matching [requestId] only when it is not expired and was - * registered for the same [userWalletId] and [currencyId]. Returns `null` otherwise, leaving a non-matching - * record untouched so a tampered redirect cannot burn a legitimate pending sell. + * Returns the pending sell matching [requestId] when it is not expired and was registered for the same + * [userWalletId] and [currencyId]; returns `null` otherwise. + * + * 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, userWalletId: UserWalletId, currencyId: String, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt b/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt index 9d23ebb812..dff5eb3a47 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt @@ -57,9 +57,11 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor( 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( + // app actually started (bound to the wallet + currency, valid until it expires). Otherwise an + // external deeplink could inject a locked attacker recipient/amount into the Send confirm + // screen. The record is kept until expiry so the user can re-open the redirect within that + // window. + val pendingOfframp = offrampRepository.resolvePendingOfframp( requestId = requestId, userWalletId = userWallet.walletId, currencyId = currencyId, diff --git a/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt index fd7a06c9a4..3180e45d7c 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt @@ -43,28 +43,43 @@ internal class DefaultSellRedirectDeepLinkHandlerTest { @Test 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()) advanceUntilIdle() - coVerify(exactly = 1) { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) } + coVerify(exactly = 1) { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) } 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 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) { offrampRepository.resolvePendingOfframp(any(), any(), any()) } coVerify(exactly = 0) { singleAccountListSupplier.getSyncOrNull(any()) } 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 + coEvery { offrampRepository.resolvePendingOfframp(requestId, userWalletId, currencyId) } returns null createHandler(validParams()) advanceUntilIdle()