From 5889f5f2fe5a23c7c13f992ea00b7fb2dce63ba6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 20 May 2026 14:13:19 -0400 Subject: [PATCH] fix(cash): detect expired blockhash and show accurate retry message When a Phantom wallet round-trip exceeds ~60s, the blockhash baked into the transaction expires and Solana rejects it with "Blockhash not found". Previously this surfaced a misleading "Make sure you have enough USDC and SOL" error. - Add TransactionExpired error variant to DeeplinkOnRampErrors - Detect blockhash errors in sendSwapTransaction before the generic FailedToSendTransaction fallback - Move processing state and navigation to onSuccess so progress only starts after the user returns from Phantom - Observe PhantomCeremonyFailed on the processing screen so dismissing the alert exits instead of leaving the user stuck Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/core/tokens/SwapStep.kt | 4 +-- .../core/src/main/res/values/strings.xml | 2 ++ .../app/tokens/PhantomWalletScreens.kt | 4 +-- .../flipcash/app/tokens/SwapEntryScreen.kt | 9 ++---- .../com/flipcash/app/tokens/SwapFlowScreen.kt | 4 +-- .../app/tokens/TokenSellReceiptScreen.kt | 5 ++- .../app/tokens/TokenTxProcessingScreen.kt | 16 ++++++---- .../app/onramp/DeeplinkOnRampErrors.kt | 7 ++++ .../app/onramp/PhantomWalletController.kt | 32 ++++++++++++++----- .../flipcash/app/onramp/DeeplinkErrorTest.kt | 19 +++++++++++ .../flipcash/app/tokens/ui/SwapViewModel.kt | 5 +-- .../kotlin/com/getcode/solana/rpc/Calls.kt | 6 +++- 12 files changed, 79 insertions(+), 34 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt index cd29dd2a5..01e639775 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt @@ -28,7 +28,5 @@ sealed interface SwapStep : FlowStep, Parcelable { data object PhantomConfirmTransaction: SwapStep @Parcelize @Serializable - data class Processing( - val swapId: SwapId, - ) : SwapStep, NonDismissableRoute, NonDraggableRoute + data object Processing : SwapStep, NonDismissableRoute, NonDraggableRoute } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index e93d3261b..ea1791795 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -234,6 +234,8 @@ Something Went Wrong Something Went Wrong Transaction Failed + Transaction Expired + The transaction took too long to process. Please try again Something Went Wrong Something Went Wrong Request Rejected in %1$s diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt index 6e433dcef..44776c399 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt @@ -132,8 +132,8 @@ internal fun PhantomTransactionConfirmationScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { event -> - flowNavigator.navigateTo(SwapStep.Processing(event.swapId)) + .onEach { + flowNavigator.navigateTo(SwapStep.Processing) }.launchIn(this) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index 79de8b9d7..9967560c7 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -89,9 +89,8 @@ internal fun SwapEntryScreen( LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .map { it.swapId } - .onEach { swapId -> - flowNavigator.navigateTo(SwapStep.Processing(swapId)) + .onEach { + flowNavigator.navigateTo(SwapStep.Processing) }.launchIn(this) } @@ -130,9 +129,7 @@ internal fun SwapEntryScreen( coinbaseOnRampController.pendingNavigation.collect { route -> if (route is AppRoute.Token.TxProcessing) { viewModel.dispatchEvent(SwapViewModel.Event.OnSwapIdChanged(route.swapId)) - flowNavigator.navigateTo( - SwapStep.Processing(route.swapId) - ) + flowNavigator.navigateTo(SwapStep.Processing) } } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt index 471a81ca6..73c18e075 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt @@ -61,7 +61,7 @@ private fun swapEntryProvider(): (NavKey) -> NavEntry = entryProvider { annotatedEntry { PhantomTransactionConfirmationScreen() } - annotatedEntry { step -> - SwapProcessingScreen(step.swapId) + annotatedEntry { + SwapProcessingScreen() } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt index d8250c310..0c7380803 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt @@ -52,9 +52,8 @@ internal fun SellReceiptScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .map { it.swapId } - .onEach { swapId -> - flowNavigator.navigateTo(SwapStep.Processing(swapId)) + .onEach { + flowNavigator.navigateTo(SwapStep.Processing) }.launchIn(this) } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt index 1bc8fb219..2330df8c7 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt @@ -24,18 +24,12 @@ import kotlinx.coroutines.flow.onEach * Flow-aware swap processing content, used inside `SwapFlowScreen`. */ @Composable -internal fun SwapProcessingScreen( - swapId: SwapId, -) { +internal fun SwapProcessingScreen() { val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() TokenTxProcessingScreen(viewModel = viewModel) - LaunchedEffect(viewModel) { - viewModel.dispatchEvent(Event.UpdateProcessingState(loading = true)) - } - LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() @@ -52,6 +46,14 @@ internal fun SwapProcessingScreen( }.launchIn(this) } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + flowNavigator.exitCanceled() + }.launchIn(this) + } + BackHandler { /* intercept */ } } diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/DeeplinkOnRampErrors.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/DeeplinkOnRampErrors.kt index 08dd68352..0d7afbd6e 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/DeeplinkOnRampErrors.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/DeeplinkOnRampErrors.kt @@ -25,6 +25,11 @@ sealed class DeeplinkOnRampError( override val cause: Throwable? = null ) : DeeplinkOnRampError(code = code, message = message, cause = cause) + class TransactionExpired( + override val message: String?, + override val cause: Throwable? = null + ) : DeeplinkOnRampError(message = message, cause = cause) + class FailedToSubmitBuyToServer( override val code: Long = -100, override val message: String?, @@ -70,6 +75,7 @@ val DeeplinkOnRampError.isAlert: Boolean DeeplinkError.Disconnected, DeeplinkError.TransactionRejected, ) || this is DeeplinkOnRampError.FailedToSendTransaction + || this is DeeplinkOnRampError.TransactionExpired || this is DeeplinkOnRampError.InsufficientSol || this is DeeplinkOnRampError.InsufficientUsdc || (this is DeeplinkOnRampError.FailedToSimulateTransaction && cause?.isNetworkError() == true) @@ -83,6 +89,7 @@ fun DeeplinkOnRampError.messaging(getString: (Int) -> String, provider: String): is DeeplinkOnRampError.FailedToCreateTransaction -> getString(R.string.error_title_deeplinkOnRampFailedToCreateTransaction) to getString(R.string.error_description_deeplinkOnRampFailedToCreateTransaction) is DeeplinkOnRampError.FailedToSimulateTransaction -> getString(R.string.error_title_deeplinkOnRampFailedToSimulateTransaction) to getString(R.string.error_description_deeplinkOnRampFailedToSimulateTransaction) is DeeplinkOnRampError.FailedToSendTransaction -> getString(R.string.error_title_deeplinkOnRampFailedToSendTransaction) to getString(R.string.error_description_deeplinkOnRampFailedToSendTransaction).format(provider) + is DeeplinkOnRampError.TransactionExpired -> getString(R.string.error_title_deeplinkOnRampTransactionExpired) to getString(R.string.error_description_deeplinkOnRampTransactionExpired) is DeeplinkOnRampError.FailedToSubmitBuyToServer -> getString(R.string.error_title_deeplinkOnRampExternalFundBuy) to getString(R.string.error_description_deeplinkOnRampExternalFundBuy).format(provider) is DeeplinkOnRampError.InsufficientSol -> getString(R.string.error_title_deeplinkOnRampInsufficientSol) to getString(R.string.error_description_deeplinkOnRampInsufficientSol).format(provider) is DeeplinkOnRampError.InsufficientUsdc -> getString(R.string.error_title_deeplinkOnRampInsufficientUsdc) to getString(R.string.error_description_deeplinkOnRampInsufficientUsdc).format(provider) diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/PhantomWalletController.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/PhantomWalletController.kt index eb48b7284..c3d7ec8ce 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/PhantomWalletController.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/PhantomWalletController.kt @@ -277,14 +277,30 @@ class PhantomWalletController @Inject constructor( } } ) - ErrorUtils.handleError(error) - return@withContext Result.failure( - DeeplinkOnRampError.FailedToSendTransaction( - code = code ?: -99L, - message = error.message, - cause = error, - ) - ) + + when { + // Detect expired blockhash — occurs when the Phantom wallet + // round-trip exceeds ~60 seconds (Solana's blockhash lifetime). + error is RpcException && error.isBlockhashNotFound -> { + ErrorUtils.handleError(error) + return@withContext Result.failure( + DeeplinkOnRampError.TransactionExpired( + message = error.message, + cause = error, + ) + ) + } + else -> { + ErrorUtils.handleError(error) + return@withContext Result.failure( + DeeplinkOnRampError.FailedToSendTransaction( + code = code ?: -99L, + message = error.message, + cause = error, + ) + ) + } + } } Result.success(Unit) diff --git a/apps/flipcash/shared/onramp/deeplinks/src/test/kotlin/com/flipcash/app/onramp/DeeplinkErrorTest.kt b/apps/flipcash/shared/onramp/deeplinks/src/test/kotlin/com/flipcash/app/onramp/DeeplinkErrorTest.kt index db7fb5d32..578a8f0dd 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/test/kotlin/com/flipcash/app/onramp/DeeplinkErrorTest.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/test/kotlin/com/flipcash/app/onramp/DeeplinkErrorTest.kt @@ -75,6 +75,7 @@ class DeeplinkErrorTest { fun `DeeplinkOnRampError subtypes are Throwable`() { val errors: List = listOf( DeeplinkOnRampError.FailedToSendTransaction(message = "fail"), + DeeplinkOnRampError.TransactionExpired(message = "expired"), DeeplinkOnRampError.WalletProvidedError( error = DeeplinkError.Disconnected, message = "disconnected" @@ -87,6 +88,24 @@ class DeeplinkErrorTest { } } + @Test + fun `TransactionExpired isAlert returns true`() { + val error = DeeplinkOnRampError.TransactionExpired(message = "Blockhash not found") + assertTrue(error.isAlert, "TransactionExpired should be an alert") + } + + @Test + fun `TransactionExpired preserves cause chain`() { + val cause = RuntimeException("Blockhash not found") + val error = DeeplinkOnRampError.TransactionExpired( + message = cause.message, + cause = cause, + ) + + assertEquals("Blockhash not found", error.message) + assertEquals(cause, error.cause) + } + @Test fun `WalletProvidedError code matches error code`() { val error = DeeplinkOnRampError.WalletProvidedError( diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 68a7a1bf1..2a8042913 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -982,14 +982,15 @@ class SwapViewModel @Inject constructor( amount = amount, fee = LocalFiat.Zero, token = token, - onBeforeSign = { swapId -> + onBeforeSign = { analytics.amountSelectedForWalletTransfer( OnRampProvider.Phantom, amount.localFiat.underlyingTokenAmount ) - dispatchEvent(Event.PhantomNavigateToProcessing(swapId)) }, ).onSuccess { swapId -> + dispatchEvent(Event.UpdateProcessingState(loading = true)) + dispatchEvent(Event.PhantomNavigateToProcessing(swapId)) dispatchEvent(Event.OnSwapIdChanged(swapId)) }.onFailure { error -> handlePhantomError(error) diff --git a/libs/crypto/solana/src/main/kotlin/com/getcode/solana/rpc/Calls.kt b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/rpc/Calls.kt index 5d58322bb..448202e98 100644 --- a/libs/crypto/solana/src/main/kotlin/com/getcode/solana/rpc/Calls.kt +++ b/libs/crypto/solana/src/main/kotlin/com/getcode/solana/rpc/Calls.kt @@ -200,4 +200,8 @@ suspend fun Rpc20Driver.simulateTransaction( class RpcException( val code: Int, override val message: String, -) : Throwable(message) \ No newline at end of file +) : Throwable(message) { + val isBlockhashNotFound: Boolean + get() = message.contains("Blockhash not found", ignoreCase = true) || + message.contains("BlockhashNotFound", ignoreCase = true) +} \ No newline at end of file