Skip to content

Commit e8de3d0

Browse files
committed
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.
1 parent 963e9fe commit e8de3d0

7 files changed

Lines changed: 161 additions & 3 deletions

File tree

apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/PhantomWalletController.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,12 @@ class PhantomWalletController @Inject constructor(
404404
)
405405
}
406406
else -> {
407-
ErrorUtils.handleError(error)
407+
// Simulation / on-chain program rejections (e.g. "custom program
408+
// error: 0x1") are expected transaction outcomes, not app defects —
409+
// surface them to the user but do not report to Bugsnag.
410+
if (!(error is RpcException && error.isSimulationError)) {
411+
ErrorUtils.handleError(error)
412+
}
408413
return@withContext Result.failure(
409414
DeeplinkOnRampError.FailedToSendTransaction(
410415
code = code ?: -99L,

libs/crypto/solana/src/main/kotlin/com/getcode/solana/rpc/Calls.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,14 @@ class RpcException(
219219
val isBlockhashNotFound: Boolean
220220
get() = message.contains("Blockhash not found", ignoreCase = true) ||
221221
message.contains("BlockhashNotFound", ignoreCase = true)
222+
223+
/**
224+
* True when the failure is a Solana transaction simulation / on-chain program
225+
* rejection (e.g. "Transaction simulation failed ... custom program error: 0x1").
226+
* These are expected transaction outcomes (insufficient funds, slippage, program
227+
* guards), NOT app defects — callers should not report them to Bugsnag.
228+
*/
229+
val isSimulationError: Boolean
230+
get() = message.contains("simulation failed", ignoreCase = true) ||
231+
message.contains("custom program error", ignoreCase = true)
222232
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.getcode.solana.rpc
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertFalse
5+
import kotlin.test.assertTrue
6+
7+
class RpcExceptionTest {
8+
9+
@Test
10+
fun simulationFailedMessageIsSimulationError() {
11+
val e = RpcException(
12+
code = -32002,
13+
message = "Transaction simulation failed: Error processing Instruction 6: custom program error: 0x1",
14+
)
15+
assertTrue(e.isSimulationError)
16+
}
17+
18+
@Test
19+
fun customProgramErrorMessageIsSimulationError() {
20+
val e = RpcException(code = -32002, message = "custom program error: 0x1")
21+
assertTrue(e.isSimulationError)
22+
}
23+
24+
@Test
25+
fun blockhashNotFoundIsNotSimulationError() {
26+
val e = RpcException(code = -32002, message = "Blockhash not found")
27+
assertTrue(e.isBlockhashNotFound)
28+
assertFalse(e.isSimulationError)
29+
}
30+
31+
@Test
32+
fun genericRpcErrorIsNotSimulationError() {
33+
val e = RpcException(code = -32000, message = "Node is behind by 42 slots")
34+
assertFalse(e.isSimulationError)
35+
}
36+
}

services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/pollers/SwapPoller.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ internal class SwapPoller @Inject constructor(
4848
type = TraceType.Error,
4949
message = "Polling timed out after $maxAttempts attempts, Swap ID: ${swapId.publicKey.base58()}"
5050
)
51-
throw SwapError.Other()
51+
throw SwapError.Timeout()
5252
}
5353

5454
val metadata = try {
@@ -71,7 +71,7 @@ internal class SwapPoller @Inject constructor(
7171
type = TraceType.Error,
7272
message = "Swap reached terminal state: ${metadata.state}, Swap ID: ${swapId.publicKey.base58()}"
7373
)
74-
throw SwapError.Other()
74+
throw SwapError.Terminal(metadata.state)
7575
}
7676
SwapState.UNKNOWN -> {
7777
trace(

services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.getcode.opencode.model.core.errors.SubmitIntentError.Other
88
import com.getcode.opencode.model.core.errors.SubmitIntentError.Signature
99
import com.getcode.opencode.model.core.errors.SubmitIntentError.StaleState
1010
import com.getcode.opencode.model.core.errors.SubmitIntentError.Unrecognized
11+
import com.getcode.opencode.model.transactions.SwapState
1112
import com.getcode.utils.CodeServerError
1213
import com.getcode.utils.ConditionallyNotifiable
1314
import com.getcode.utils.NotifiableError
@@ -278,6 +279,20 @@ sealed class SwapError(
278279
}
279280
class TransactionFailed(reasons: List<String>): SwapError(message = reasons.joinToString()), NotifiableError
280281

282+
/**
283+
* Poller exhausted all attempts without reaching the target state.
284+
* An expected outcome (slow finalization / backend delay), not an app defect —
285+
* deliberately NOT a NotifiableError so it is not reported to Bugsnag.
286+
*/
287+
class Timeout : SwapError(message = "Polling timed out")
288+
289+
/**
290+
* Backend moved the swap to a terminal non-target state (FAILED / CANCELLED).
291+
* An expected backend-driven outcome, not an app defect —
292+
* deliberately NOT a NotifiableError so it is not reported to Bugsnag.
293+
*/
294+
class Terminal(val state: SwapState) : SwapError(message = "Swap reached terminal state: $state")
295+
281296
data class Other(override val cause: Throwable? = null) : SwapError(message = cause?.message, cause = cause), NotifiableError
282297

283298
companion object {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.getcode.opencode.internal.network.pollers
2+
3+
import com.getcode.ed25519.Ed25519
4+
import com.getcode.opencode.internal.network.services.SwapService
5+
import com.getcode.opencode.internal.solana.model.SwapId
6+
import com.getcode.opencode.model.core.errors.SwapError
7+
import com.getcode.opencode.model.transactions.SwapMetadata
8+
import com.getcode.opencode.model.transactions.SwapState
9+
import com.getcode.utils.NotifiableError
10+
import io.mockk.coEvery
11+
import io.mockk.every
12+
import io.mockk.mockk
13+
import kotlinx.coroutines.test.runTest
14+
import kotlin.test.Test
15+
import kotlin.test.assertEquals
16+
import kotlin.test.assertFalse
17+
import kotlin.test.assertIs
18+
import kotlin.test.assertTrue
19+
import kotlin.time.Duration.Companion.seconds
20+
21+
class SwapPollerTest {
22+
23+
private val owner = mockk<Ed25519.KeyPair>(relaxed = true)
24+
private val swapId = SwapId.generate()
25+
26+
private fun serviceReturning(state: SwapState): SwapService {
27+
val metadata = mockk<SwapMetadata> { every { this@mockk.state } returns state }
28+
return mockk<SwapService> {
29+
coEvery { getSwap(swapId, owner) } returns Result.success(metadata)
30+
}
31+
}
32+
33+
@Test
34+
fun terminalCancelledThrowsNonNotifiableTerminalError() = runTest {
35+
val poller = SwapPoller(serviceReturning(SwapState.CANCELLED))
36+
37+
val result = poller.pollUntil(
38+
swapId = swapId,
39+
owner = owner,
40+
targetState = SwapState.FINALIZED,
41+
maxAttempts = 3,
42+
interval = 0.seconds,
43+
)
44+
45+
assertTrue(result.isFailure)
46+
val error = result.exceptionOrNull()
47+
assertIs<SwapError.Terminal>(error)
48+
assertEquals(SwapState.CANCELLED, error.state)
49+
assertFalse(error is NotifiableError)
50+
}
51+
52+
@Test
53+
fun exhaustingAttemptsThrowsNonNotifiableTimeoutError() = runTest {
54+
// FUNDING is neither the target, terminal, nor UNKNOWN — poller recurses until timeout.
55+
val poller = SwapPoller(serviceReturning(SwapState.FUNDING))
56+
57+
val result = poller.pollUntil(
58+
swapId = swapId,
59+
owner = owner,
60+
targetState = SwapState.FINALIZED,
61+
maxAttempts = 2,
62+
interval = 0.seconds,
63+
)
64+
65+
assertTrue(result.isFailure)
66+
val error = result.exceptionOrNull()
67+
assertIs<SwapError.Timeout>(error)
68+
assertFalse(error is NotifiableError)
69+
}
70+
}

services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SwapErrorTest.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import com.codeinc.opencode.gen.transaction.v1.OcpTransactionService.StatefulSwa
44
import com.codeinc.opencode.gen.transaction.v1.errorDetails
55
import com.codeinc.opencode.gen.transaction.v1.reasonStringErrorDetails
66
import com.codeinc.opencode.gen.transaction.v1.deniedErrorDetails
7+
import com.getcode.opencode.model.transactions.SwapState
8+
import com.getcode.utils.NotifiableError
79
import kotlin.test.Test
810
import kotlin.test.assertEquals
11+
import kotlin.test.assertFalse
912
import kotlin.test.assertIs
1013
import kotlin.test.assertTrue
1114

@@ -120,4 +123,23 @@ class SwapErrorTest {
120123
assertEquals(cause, error.cause)
121124
assertEquals("swap failed", error.message)
122125
}
126+
127+
// --- Notifiability of expected outcomes ---
128+
129+
@Test
130+
fun timeoutIsNotNotifiable() {
131+
assertFalse(SwapError.Timeout() is NotifiableError)
132+
}
133+
134+
@Test
135+
fun terminalIsNotNotifiableAndCarriesState() {
136+
val error = SwapError.Terminal(SwapState.CANCELLED)
137+
assertFalse(error is NotifiableError)
138+
assertEquals(SwapState.CANCELLED, error.state)
139+
}
140+
141+
@Test
142+
fun otherRemainsNotifiable() {
143+
assertTrue(SwapError.Other() is NotifiableError)
144+
}
123145
}

0 commit comments

Comments
 (0)