From e8de3d01fe0e033fe146276d29003e1639300fbb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 10 Jul 2026 09:53:16 -0400 Subject: [PATCH] fix: stop reporting expected swap and on-ramp outcomes as notifiable Bugsnag warnings Two production Bugsnag "warning" issues were expected outcomes, not app defects: - Swap poll timeouts and backend-driven terminal states (FAILED/CANCELLED) threw a bare SwapError.Other() (a NotifiableError). Add dedicated SwapError.Timeout and SwapError.Terminal(state) subtypes that are not NotifiableError, and throw them from SwapPoller. Genuinely unexpected cases (UNKNOWN state, getSwap failures) still throw SwapError.Other. - Solana transaction simulation / on-chain program rejections (e.g. "custom program error: 0x1") were reported via ErrorUtils.handleError. Add RpcException.isSimulationError and skip the Bugsnag report for those in PhantomWalletController, while still surfacing the typed failure to the user. --- .../app/onramp/PhantomWalletController.kt | 7 +- .../kotlin/com/getcode/solana/rpc/Calls.kt | 10 +++ .../getcode/solana/rpc/RpcExceptionTest.kt | 36 ++++++++++ .../internal/network/pollers/SwapPoller.kt | 4 +- .../opencode/model/core/errors/Errors.kt | 15 ++++ .../network/pollers/SwapPollerTest.kt | 70 +++++++++++++++++++ .../model/core/errors/SwapErrorTest.kt | 22 ++++++ 7 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/solana/rpc/RpcExceptionTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/pollers/SwapPollerTest.kt 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 2e33c2ae8..beb526fec 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 @@ -404,7 +404,12 @@ class PhantomWalletController @Inject constructor( ) } else -> { - ErrorUtils.handleError(error) + // Simulation / on-chain program rejections (e.g. "custom program + // error: 0x1") are expected transaction outcomes, not app defects — + // surface them to the user but do not report to Bugsnag. + if (!(error is RpcException && error.isSimulationError)) { + ErrorUtils.handleError(error) + } return@withContext Result.failure( DeeplinkOnRampError.FailedToSendTransaction( code = code ?: -99L, 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 fd0332cac..054ddf8a5 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 @@ -219,4 +219,14 @@ class RpcException( val isBlockhashNotFound: Boolean get() = message.contains("Blockhash not found", ignoreCase = true) || message.contains("BlockhashNotFound", ignoreCase = true) + + /** + * True when the failure is a Solana transaction simulation / on-chain program + * rejection (e.g. "Transaction simulation failed ... custom program error: 0x1"). + * These are expected transaction outcomes (insufficient funds, slippage, program + * guards), NOT app defects — callers should not report them to Bugsnag. + */ + val isSimulationError: Boolean + get() = message.contains("simulation failed", ignoreCase = true) || + message.contains("custom program error", ignoreCase = true) } \ No newline at end of file diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/rpc/RpcExceptionTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/rpc/RpcExceptionTest.kt new file mode 100644 index 000000000..a73b6a222 --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/rpc/RpcExceptionTest.kt @@ -0,0 +1,36 @@ +package com.getcode.solana.rpc + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RpcExceptionTest { + + @Test + fun simulationFailedMessageIsSimulationError() { + val e = RpcException( + code = -32002, + message = "Transaction simulation failed: Error processing Instruction 6: custom program error: 0x1", + ) + assertTrue(e.isSimulationError) + } + + @Test + fun customProgramErrorMessageIsSimulationError() { + val e = RpcException(code = -32002, message = "custom program error: 0x1") + assertTrue(e.isSimulationError) + } + + @Test + fun blockhashNotFoundIsNotSimulationError() { + val e = RpcException(code = -32002, message = "Blockhash not found") + assertTrue(e.isBlockhashNotFound) + assertFalse(e.isSimulationError) + } + + @Test + fun genericRpcErrorIsNotSimulationError() { + val e = RpcException(code = -32000, message = "Node is behind by 42 slots") + assertFalse(e.isSimulationError) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/pollers/SwapPoller.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/pollers/SwapPoller.kt index 62e043559..bbf723de2 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/pollers/SwapPoller.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/pollers/SwapPoller.kt @@ -48,7 +48,7 @@ internal class SwapPoller @Inject constructor( type = TraceType.Error, message = "Polling timed out after $maxAttempts attempts, Swap ID: ${swapId.publicKey.base58()}" ) - throw SwapError.Other() + throw SwapError.Timeout() } val metadata = try { @@ -71,7 +71,7 @@ internal class SwapPoller @Inject constructor( type = TraceType.Error, message = "Swap reached terminal state: ${metadata.state}, Swap ID: ${swapId.publicKey.base58()}" ) - throw SwapError.Other() + throw SwapError.Terminal(metadata.state) } SwapState.UNKNOWN -> { trace( diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt index cffdb56ac..6efc41bc8 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt @@ -8,6 +8,7 @@ import com.getcode.opencode.model.core.errors.SubmitIntentError.Other import com.getcode.opencode.model.core.errors.SubmitIntentError.Signature import com.getcode.opencode.model.core.errors.SubmitIntentError.StaleState import com.getcode.opencode.model.core.errors.SubmitIntentError.Unrecognized +import com.getcode.opencode.model.transactions.SwapState import com.getcode.utils.CodeServerError import com.getcode.utils.ConditionallyNotifiable import com.getcode.utils.NotifiableError @@ -278,6 +279,20 @@ sealed class SwapError( } class TransactionFailed(reasons: List): SwapError(message = reasons.joinToString()), NotifiableError + /** + * Poller exhausted all attempts without reaching the target state. + * An expected outcome (slow finalization / backend delay), not an app defect — + * deliberately NOT a NotifiableError so it is not reported to Bugsnag. + */ + class Timeout : SwapError(message = "Polling timed out") + + /** + * Backend moved the swap to a terminal non-target state (FAILED / CANCELLED). + * An expected backend-driven outcome, not an app defect — + * deliberately NOT a NotifiableError so it is not reported to Bugsnag. + */ + class Terminal(val state: SwapState) : SwapError(message = "Swap reached terminal state: $state") + data class Other(override val cause: Throwable? = null) : SwapError(message = cause?.message, cause = cause), NotifiableError companion object { diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/pollers/SwapPollerTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/pollers/SwapPollerTest.kt new file mode 100644 index 000000000..8e0621d77 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/pollers/SwapPollerTest.kt @@ -0,0 +1,70 @@ +package com.getcode.opencode.internal.network.pollers + +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.internal.network.services.SwapService +import com.getcode.opencode.internal.solana.model.SwapId +import com.getcode.opencode.model.core.errors.SwapError +import com.getcode.opencode.model.transactions.SwapMetadata +import com.getcode.opencode.model.transactions.SwapState +import com.getcode.utils.NotifiableError +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class SwapPollerTest { + + private val owner = mockk(relaxed = true) + private val swapId = SwapId.generate() + + private fun serviceReturning(state: SwapState): SwapService { + val metadata = mockk { every { this@mockk.state } returns state } + return mockk { + coEvery { getSwap(swapId, owner) } returns Result.success(metadata) + } + } + + @Test + fun terminalCancelledThrowsNonNotifiableTerminalError() = runTest { + val poller = SwapPoller(serviceReturning(SwapState.CANCELLED)) + + val result = poller.pollUntil( + swapId = swapId, + owner = owner, + targetState = SwapState.FINALIZED, + maxAttempts = 3, + interval = 0.seconds, + ) + + assertTrue(result.isFailure) + val error = result.exceptionOrNull() + assertIs(error) + assertEquals(SwapState.CANCELLED, error.state) + assertFalse(error is NotifiableError) + } + + @Test + fun exhaustingAttemptsThrowsNonNotifiableTimeoutError() = runTest { + // FUNDING is neither the target, terminal, nor UNKNOWN — poller recurses until timeout. + val poller = SwapPoller(serviceReturning(SwapState.FUNDING)) + + val result = poller.pollUntil( + swapId = swapId, + owner = owner, + targetState = SwapState.FINALIZED, + maxAttempts = 2, + interval = 0.seconds, + ) + + assertTrue(result.isFailure) + val error = result.exceptionOrNull() + assertIs(error) + assertFalse(error is NotifiableError) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SwapErrorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SwapErrorTest.kt index 95d8971ca..76dcc5345 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SwapErrorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SwapErrorTest.kt @@ -4,8 +4,11 @@ import com.codeinc.opencode.gen.transaction.v1.OcpTransactionService.StatefulSwa import com.codeinc.opencode.gen.transaction.v1.errorDetails import com.codeinc.opencode.gen.transaction.v1.reasonStringErrorDetails import com.codeinc.opencode.gen.transaction.v1.deniedErrorDetails +import com.getcode.opencode.model.transactions.SwapState +import com.getcode.utils.NotifiableError import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue @@ -120,4 +123,23 @@ class SwapErrorTest { assertEquals(cause, error.cause) assertEquals("swap failed", error.message) } + + // --- Notifiability of expected outcomes --- + + @Test + fun timeoutIsNotNotifiable() { + assertFalse(SwapError.Timeout() is NotifiableError) + } + + @Test + fun terminalIsNotNotifiableAndCarriesState() { + val error = SwapError.Terminal(SwapState.CANCELLED) + assertFalse(error is NotifiableError) + assertEquals(SwapState.CANCELLED, error.state) + } + + @Test + fun otherRemainsNotifiable() { + assertTrue(SwapError.Other() is NotifiableError) + } }