Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions libs/crypto/solana/src/main/kotlin/com/getcode/solana/rpc/Calls.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -278,6 +279,20 @@ sealed class SwapError(
}
class TransactionFailed(reasons: List<String>): 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Ed25519.KeyPair>(relaxed = true)
private val swapId = SwapId.generate()

private fun serviceReturning(state: SwapState): SwapService {
val metadata = mockk<SwapMetadata> { every { this@mockk.state } returns state }
return mockk<SwapService> {
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<SwapError.Terminal>(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<SwapError.Timeout>(error)
assertFalse(error is NotifiableError)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
}
Loading