From 290b532b80d2331a7cf9ee8d6d8f0621df77e5d9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 18:04:56 -0300 Subject: [PATCH 1/8] fix: show specific electrum error toasts Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/advanced/ElectrumConfigScreen.kt | 28 ----- .../advanced/ElectrumConfigViewModel.kt | 64 ++++++----- app/src/main/res/values/strings.xml | 2 + .../advanced/ElectrumConfigViewModelTest.kt | 104 ++++++++++++++++++ changelog.d/next/1177.fixed.md | 1 + 5 files changed, 141 insertions(+), 58 deletions(-) create mode 100644 changelog.d/next/1177.fixed.md diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt index 1fb2e584f5..903702d00f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt @@ -12,10 +12,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -29,7 +27,6 @@ import androidx.navigation.NavController import to.bitkit.R import to.bitkit.models.ElectrumProtocol import to.bitkit.models.ElectrumServerPeer -import to.bitkit.models.Toast import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up @@ -53,31 +50,6 @@ fun ElectrumConfigScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val app = appViewModel ?: return - val context = LocalContext.current - - // Monitor connection results - LaunchedEffect(uiState.connectionResult) { - uiState.connectionResult?.let { result -> - if (result.isSuccess) { - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.settings__es__server_updated_title), - description = context.getString(R.string.settings__es__server_updated_message) - .replace("{host}", uiState.host) - .replace("{port}", uiState.port), - testTag = "ElectrumUpdatedToast", - ) - } else { - app.toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.settings__es__server_error), - description = context.getString(R.string.settings__es__server_error_description), - testTag = "ElectrumErrorToast", - ) - } - viewModel.clearConnectionResult() - } - } Content( uiState = uiState, diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt index 5ebac472d5..7c0f2ebdf4 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.settings.advanced import android.content.Context +import androidx.annotation.StringRes import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -26,6 +27,7 @@ import to.bitkit.models.MAX_VALID_PORT import to.bitkit.models.Toast import to.bitkit.models.getDefaultPort import to.bitkit.repositories.LightningRepo +import to.bitkit.services.ElectrumProbeError import to.bitkit.ui.shared.toast.ToastEventBus import javax.inject.Inject @@ -150,33 +152,33 @@ class ElectrumConfigViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } viewModelScope.launch(bgDispatcher) { - runCatching { - val electrumServer = ElectrumServer.fromUserInput( - host = currentState.host, - port = port, - protocol = protocol, - ) - val serverUrl = electrumServer.toString() - - lightningRepo.restartWithElectrumServer(serverUrl) - .onSuccess { - _uiState.update { - it.copy( - isLoading = false, - connectionResult = Result.success(Unit), - hasEdited = false, - ) - } - } - .onFailure { error -> throw error } - }.onFailure { e -> - _uiState.update { - it.copy( - isLoading = false, - connectionResult = Result.failure(e), + val serverUrl = ElectrumServer.fromUserInput( + host = currentState.host, + port = port, + protocol = protocol, + ).toString() + + lightningRepo.restartWithElectrumServer(serverUrl) + .onSuccess { + _uiState.update { it.copy(isLoading = false, hasEdited = false) } + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.settings__es__server_updated_title), + description = context.getString(R.string.settings__es__server_updated_message) + .replace("{host}", currentState.host) + .replace("{port}", currentState.port), + testTag = "ElectrumUpdatedToast", + ) + } + .onFailure { error -> + _uiState.update { it.copy(isLoading = false) } + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.settings__es__server_error), + description = context.getString(error.toServerErrorDescriptionRes()), + testTag = "ElectrumErrorToast", ) } - } } } @@ -248,10 +250,6 @@ class ElectrumConfigViewModel @Inject constructor( return uiPeer != state.connectedPeer } - fun clearConnectionResult() { - _uiState.update { it.copy(connectionResult = null) } - } - fun onClickConnect() { viewModelScope.launch(bgDispatcher) { val validationError = validateInput() @@ -357,6 +355,12 @@ data class ElectrumConfigUiState( val port: String = "", val protocol: ElectrumProtocol? = null, val isLoading: Boolean = false, - val connectionResult: Result? = null, val hasEdited: Boolean = false, ) + +@StringRes +private fun Throwable.toServerErrorDescriptionRes(): Int = when (this) { + is ElectrumProbeError.NetworkMismatch -> R.string.settings__es__server_error_network + is ElectrumProbeError.ProtocolMismatch -> R.string.settings__es__server_error_protocol + else -> R.string.settings__es__server_error_description +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80e2fbddc7..0f4b11ea65 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -908,6 +908,8 @@ Protocol Electrum Connection Failed Bitkit could not establish a connection to Electrum. + This server is on a different Bitcoin network. Choose a server for the network Bitkit is using. + Secure connection failed. Check that the protocol (TCP or TLS) matches the server port. Successfully connected to {host}:{port} Electrum Server Updated Depends on fee diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt index 5eab60bc1e..73d0ffcf63 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt @@ -4,6 +4,8 @@ import android.content.Context import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.withTimeout import org.junit.Before @@ -19,9 +21,17 @@ import org.robolectric.annotation.Config import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.models.ElectrumProtocol +import to.bitkit.models.ElectrumServer +import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.services.ElectrumProbeError import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.time.Duration.Companion.seconds @@ -42,6 +52,13 @@ class ElectrumConfigViewModelTest : BaseUnitTest() { private val errorPortInvalid = "Please specify a valid port." private val errorInvalidHttp = "Please specify a valid url." private val errorPeer = "Electrum Error" + private val serverError = "Electrum Connection Failed" + private val serverErrorDescription = "Bitkit could not establish a connection to Electrum." + private val serverErrorNetwork = "This server is on a different Bitcoin network." + private val serverErrorProtocol = "Secure connection failed." + private val serverUpdatedTitle = "Electrum Server Updated" + private val serverUpdatedMessage = "Successfully connected to {host}:{port}" + private val server = ElectrumServer(host = "example.com", tcp = 50001, ssl = 50002, protocol = ElectrumProtocol.SSL) @Before fun setUp() { @@ -51,6 +68,12 @@ class ElectrumConfigViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.settings__es__error_port_invalid)).thenReturn(errorPortInvalid) whenever(context.getString(R.string.settings__es__error_invalid_http)).thenReturn(errorInvalidHttp) whenever(context.getString(R.string.settings__es__error_peer)).thenReturn(errorPeer) + whenever(context.getString(R.string.settings__es__server_error)).thenReturn(serverError) + whenever(context.getString(R.string.settings__es__server_error_description)).thenReturn(serverErrorDescription) + whenever(context.getString(R.string.settings__es__server_error_network)).thenReturn(serverErrorNetwork) + whenever(context.getString(R.string.settings__es__server_error_protocol)).thenReturn(serverErrorProtocol) + whenever(context.getString(R.string.settings__es__server_updated_title)).thenReturn(serverUpdatedTitle) + whenever(context.getString(R.string.settings__es__server_updated_message)).thenReturn(serverUpdatedMessage) whenever(settingsStore.data).thenReturn( flowOf(SettingsData(electrumServer = "ssl://electrum.blockstream.info:50002")) ) @@ -110,4 +133,85 @@ class ElectrumConfigViewModelTest : BaseUnitTest() { verify(lightningRepo, never()).restartWithElectrumServer(any()) } + + @Test + fun `connectToServer shows network toast on network mismatch`() = test { + val error = ElectrumProbeError.NetworkMismatch(server, expected = "regtest", actual = "bitcoin") + + val toast = connectAndCollectToast(Result.failure(error)) + + assertErrorToast(serverErrorNetwork, toast) + } + + @Test + fun `connectToServer shows protocol toast on protocol mismatch`() = test { + val error = ElectrumProbeError.ProtocolMismatch(server, AppError("handshake")) + + val toast = connectAndCollectToast(Result.failure(error)) + + assertErrorToast(serverErrorProtocol, toast) + } + + @Test + fun `connectToServer shows generic toast when server is unreachable`() = test { + val error = ElectrumProbeError.Unreachable(server, AppError("timeout")) + + val toast = connectAndCollectToast(Result.failure(error)) + + assertErrorToast(serverErrorDescription, toast) + } + + @Test + fun `connectToServer shows generic toast when server is not electrum`() = test { + val error = ElectrumProbeError.NotElectrum(server) + + val toast = connectAndCollectToast(Result.failure(error)) + + assertErrorToast(serverErrorDescription, toast) + } + + @Test + fun `connectToServer shows generic toast on other failures`() = test { + val toast = connectAndCollectToast(Result.failure(AppError("node failed to start"))) + + assertErrorToast(serverErrorDescription, toast) + } + + @Test + fun `connectToServer shows updated toast on success`() = test { + val toast = connectAndCollectToast(Result.success(Unit)) + + assertEquals(Toast.ToastType.SUCCESS, toast.type) + assertEquals(serverUpdatedTitle, toast.title) + assertEquals("Successfully connected to example.com:50002", toast.description) + assertEquals("ElectrumUpdatedToast", toast.testTag) + assertFalse(sut.uiState.value.isLoading) + assertFalse(sut.uiState.value.hasEdited) + } + + private suspend fun TestScope.connectAndCollectToast(result: Result): Toast { + whenever(lightningRepo.restartWithElectrumServer("ssl://example.com:50002")).thenReturn(result) + sut = createSut() + advanceUntilIdle() + sut.setProtocol(ElectrumProtocol.SSL) + sut.setHost("example.com") + sut.setPort("50002") + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + advanceUntilIdle() + sut.connectToServer() + advanceUntilIdle() + collectJob.cancel() + + return toasts.single() + } + + private fun assertErrorToast(expectedDescription: String, toast: Toast) { + assertEquals(Toast.ToastType.WARNING, toast.type) + assertEquals(serverError, toast.title) + assertEquals(expectedDescription, toast.description) + assertEquals("ElectrumErrorToast", toast.testTag) + assertFalse(sut.uiState.value.isLoading) + } } diff --git a/changelog.d/next/1177.fixed.md b/changelog.d/next/1177.fixed.md new file mode 100644 index 0000000000..49c24fcf33 --- /dev/null +++ b/changelog.d/next/1177.fixed.md @@ -0,0 +1 @@ +Connecting to a custom Electrum server now explains when it is on the wrong Bitcoin network or when the TCP/TLS protocol does not match the port. From a008aae12c3aeb1b72639f82a85e9d7fc1ec5bfc Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 19:05:57 -0300 Subject: [PATCH 2/8] fix: return failure when electrum server write fails Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/repositories/LightningRepo.kt | 6 +++++- .../to/bitkit/repositories/LightningRepoTest.kt | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 41fc0b3621..170581b399 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -862,7 +862,11 @@ class LightningRepo @Inject constructor( Logger.warn("Failed ldk-node config change, recovering in background…", context = TAG) scope.launch { restartWithPreviousConfig() } }.onSuccess { - settingsStore.update { it.copy(electrumServer = newServerUrl) } + runSuspendCatching { settingsStore.update { it.copy(electrumServer = newServerUrl) } } + .onFailure { + Logger.error("Failed to persist electrum server '$newServerUrl'", it, context = TAG) + return@withContext Result.failure(it) + } Logger.info("Successfully changed electrum server", context = TAG) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 53d5756cfa..0a4a518e3a 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1033,6 +1033,22 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } + // Regression: the settings write after a successful restart must surface as a failed Result. + // Thrown out of the returned Result it escapes the caller's coroutine, which leaves the + // Electrum config screen spinning with no toast. + @Test + fun `restartWithElectrumServer returns failure when persisting the server fails`() = test { + startNodeForTesting() + val customServerUrl = "ssl://test.example.com:50002" + whenever(lightningService.node).thenReturn(null) + whenever(lightningService.stop()).thenReturn(Unit) + whenever(settingsStore.update(any())).thenThrow(RuntimeException("write failed")) + + val result = sut.restartWithElectrumServer(customServerUrl) + + assertTrue(result.isFailure) + } + @Test fun `restartWithElectrumServer should handle stop failure`() = test { startNodeForTesting() From 57b4e94154b2444a30b9feccc0cded1ccf6fa666 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 08:11:58 -0300 Subject: [PATCH 3/8] fix: show certificate toast for untrusted electrum server Co-Authored-By: Claude Opus 5 (1M context) --- .../bitkit/services/ElectrumProbeService.kt | 34 ++++++++++++++- .../advanced/ElectrumConfigViewModel.kt | 1 + app/src/main/res/values/strings.xml | 1 + .../services/ElectrumProbeServiceTest.kt | 43 +++++++++++++++++++ .../advanced/ElectrumConfigViewModelTest.kt | 12 ++++++ 5 files changed, 90 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt b/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt index d13f821d1e..1d8dc40eed 100644 --- a/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt +++ b/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt @@ -22,8 +22,11 @@ import java.io.InputStream import java.io.Writer import java.net.InetSocketAddress import java.net.Socket +import java.security.cert.CertPathValidatorException +import java.security.cert.CertificateException import javax.inject.Inject import javax.inject.Singleton +import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import kotlin.time.Duration.Companion.seconds @@ -117,7 +120,7 @@ class ElectrumProbeService @Inject constructor( ssl }.getOrElse { plain.runCatching { close() } - throw ElectrumProbeError.ProtocolMismatch(server, it) + throw it.toTlsProbeError(server) } } @@ -216,6 +219,32 @@ private data class RpcResponse( private fun JsonElement?.isNullOrJsonNull() = this == null || this is JsonNull +/** Depth the handshake failure's cause chain is walked to, enough for JSSE's wrapping and cycle-proof. */ +private const val MAX_CAUSE_DEPTH = 8 + +// A failed TLS handshake is only evidence of the wrong protocol or port when the peer did not speak +// TLS at all. A server that presented a certificate Bitkit does not trust — self-signed Fulcrum or +// electrs on its SSL port — has the protocol right, so it must not be told to check it. +internal fun Throwable.toTlsProbeError(server: ElectrumServer): ElectrumProbeError = + if (isCertificateFailure()) { + ElectrumProbeError.UntrustedCertificate(server, this) + } else { + ElectrumProbeError.ProtocolMismatch(server, this) + } + +private fun Throwable.isCertificateFailure(): Boolean { + var cause: Throwable? = this + var depth = 0 + while (cause != null && depth < MAX_CAUSE_DEPTH) { + when (cause) { + is CertificateException, is CertPathValidatorException, is SSLPeerUnverifiedException -> return true + else -> cause = cause.cause + } + depth++ + } + return false +} + sealed class ElectrumProbeError(message: String, cause: Throwable? = null) : AppError(message, cause) { class Unreachable(server: ElectrumServer, cause: Throwable) : ElectrumProbeError("Could not reach electrum server '$server'", cause) @@ -223,6 +252,9 @@ sealed class ElectrumProbeError(message: String, cause: Throwable? = null) : App class ProtocolMismatch(server: ElectrumServer, cause: Throwable) : ElectrumProbeError("Failed TLS handshake with electrum server '$server', check the protocol", cause) + class UntrustedCertificate(server: ElectrumServer, cause: Throwable) : + ElectrumProbeError("Rejected untrusted certificate of electrum server '$server'", cause) + class NotElectrum(server: ElectrumServer, cause: Throwable? = null) : ElectrumProbeError("Received no electrum response from '$server'", cause) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt index 7c0f2ebdf4..6ca3dd4b1a 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt @@ -362,5 +362,6 @@ data class ElectrumConfigUiState( private fun Throwable.toServerErrorDescriptionRes(): Int = when (this) { is ElectrumProbeError.NetworkMismatch -> R.string.settings__es__server_error_network is ElectrumProbeError.ProtocolMismatch -> R.string.settings__es__server_error_protocol + is ElectrumProbeError.UntrustedCertificate -> R.string.settings__es__server_error_certificate else -> R.string.settings__es__server_error_description } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f4b11ea65..3260295d51 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -907,6 +907,7 @@ Port Protocol Electrum Connection Failed + This server\'s certificate is not trusted. Use a server with a trusted certificate, or connect to its TCP port. Bitkit could not establish a connection to Electrum. This server is on a different Bitcoin network. Choose a server for the network Bitkit is using. Secure connection failed. Check that the protocol (TCP or TLS) matches the server port. diff --git a/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt b/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt index b3a53b4909..39d2228206 100644 --- a/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt @@ -13,10 +13,16 @@ import org.lightningdevkit.ldknode.Network import to.bitkit.models.ElectrumProtocol import to.bitkit.models.ElectrumServer import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import java.io.BufferedReader import java.net.InetAddress import java.net.ServerSocket import java.net.Socket +import java.net.SocketTimeoutException +import java.security.cert.CertPathValidatorException +import javax.net.ssl.SSLException +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException import kotlin.concurrent.thread import kotlin.test.assertEquals import kotlin.test.assertIs @@ -210,6 +216,43 @@ class ElectrumProbeServiceTest : BaseUnitTest() { assertTrue(features.getValue("params").jsonArray.isEmpty()) } + // A self-signed Fulcrum or electrs on its SSL port fails the handshake with the protocol and the + // port already correct, so it must not be reported as a protocol mismatch. + @Test + fun `toTlsProbeError reports an untrusted certificate chain`() { + val handshake = SSLHandshakeException("PKIX path building failed").apply { + initCause(AppError("validator failed", CertPathValidatorException("no trusted path"))) + } + + val error = handshake.toTlsProbeError(serverAt(50002, ElectrumProtocol.SSL)) + + assertIs(error) + } + + @Test + fun `toTlsProbeError reports an unverified peer`() { + val error = SSLPeerUnverifiedException("hostname mismatch") + .toTlsProbeError(serverAt(50002, ElectrumProtocol.SSL)) + + assertIs(error) + } + + @Test + fun `toTlsProbeError reports a handshake timeout as a protocol mismatch`() { + val error = SocketTimeoutException("Read timed out") + .toTlsProbeError(serverAt(50002, ElectrumProtocol.SSL)) + + assertIs(error) + } + + @Test + fun `toTlsProbeError reports a non tls reply as a protocol mismatch`() { + val error = SSLException("Unsupported or unrecognized SSL message") + .toTlsProbeError(serverAt(50002, ElectrumProtocol.SSL)) + + assertIs(error) + } + private fun serverAt(port: Int, protocol: ElectrumProtocol = ElectrumProtocol.TCP) = ElectrumServer( host = "127.0.0.1", tcp = port, diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt index 73d0ffcf63..1dc451e34b 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt @@ -56,6 +56,7 @@ class ElectrumConfigViewModelTest : BaseUnitTest() { private val serverErrorDescription = "Bitkit could not establish a connection to Electrum." private val serverErrorNetwork = "This server is on a different Bitcoin network." private val serverErrorProtocol = "Secure connection failed." + private val serverErrorCertificate = "This server's certificate is not trusted." private val serverUpdatedTitle = "Electrum Server Updated" private val serverUpdatedMessage = "Successfully connected to {host}:{port}" private val server = ElectrumServer(host = "example.com", tcp = 50001, ssl = 50002, protocol = ElectrumProtocol.SSL) @@ -72,6 +73,8 @@ class ElectrumConfigViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.settings__es__server_error_description)).thenReturn(serverErrorDescription) whenever(context.getString(R.string.settings__es__server_error_network)).thenReturn(serverErrorNetwork) whenever(context.getString(R.string.settings__es__server_error_protocol)).thenReturn(serverErrorProtocol) + whenever(context.getString(R.string.settings__es__server_error_certificate)) + .thenReturn(serverErrorCertificate) whenever(context.getString(R.string.settings__es__server_updated_title)).thenReturn(serverUpdatedTitle) whenever(context.getString(R.string.settings__es__server_updated_message)).thenReturn(serverUpdatedMessage) whenever(settingsStore.data).thenReturn( @@ -152,6 +155,15 @@ class ElectrumConfigViewModelTest : BaseUnitTest() { assertErrorToast(serverErrorProtocol, toast) } + @Test + fun `connectToServer shows certificate toast on untrusted certificate`() = test { + val error = ElectrumProbeError.UntrustedCertificate(server, AppError("PKIX path building failed")) + + val toast = connectAndCollectToast(Result.failure(error)) + + assertErrorToast(serverErrorCertificate, toast) + } + @Test fun `connectToServer shows generic toast when server is unreachable`() = test { val error = ElectrumProbeError.Unreachable(server, AppError("timeout")) From 5291fe216c02b0bdbaf23d6e5d1f294af8fb2c86 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 09:13:01 -0300 Subject: [PATCH 4/8] fix: verify the certificate hostname when probing electrum over tls Co-Authored-By: Claude Opus 5 (1M context) --- .../bitkit/services/ElectrumProbeService.kt | 7 + .../services/ElectrumProbeServiceTest.kt | 135 +++++++++++++++++- changelog.d/next/1177.fixed.md | 2 +- 3 files changed, 137 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt b/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt index 1d8dc40eed..d348d13628 100644 --- a/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt +++ b/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt @@ -62,6 +62,9 @@ class ElectrumProbeService @Inject constructor( /** JSON-RPC id of the `server.features` request. */ private const val FEATURES_REQUEST_ID = 1 + + /** JSSE endpoint identification that checks the certificate's name, not only its chain. */ + private const val HOSTNAME_VERIFICATION = "HTTPS" } // Deliberately not the injected Json: that one sets prettyPrint, and electrum is line-delimited, @@ -116,6 +119,10 @@ class ElectrumProbeService @Inject constructor( val factory = SSLSocketFactory.getDefault() as SSLSocketFactory val ssl = factory.createSocket(plain, server.host, server.getPort(), true) as SSLSocket ssl.soTimeout = CONNECT_TIMEOUT.inWholeMilliseconds.toInt() + // A raw SSLSocket validates the chain but not the name the certificate was issued for, so + // a CA-valid certificate for another host probes clean and is only rejected afterwards by + // the node's own electrum client — after the restart this probe exists to avoid. + ssl.sslParameters = ssl.sslParameters.apply { endpointIdentificationAlgorithm = HOSTNAME_VERIFICATION } ssl.startHandshake() ssl }.getOrElse { diff --git a/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt b/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt index 39d2228206..1bd254aed0 100644 --- a/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt @@ -7,40 +7,73 @@ import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNames +import org.bouncycastle.x509.X509V3CertificateGenerator import org.junit.After import org.junit.Test import org.lightningdevkit.ldknode.Network +import to.bitkit.ext.nowMillis import to.bitkit.models.ElectrumProtocol import to.bitkit.models.ElectrumServer import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import java.io.BufferedReader +import java.math.BigInteger import java.net.InetAddress import java.net.ServerSocket import java.net.Socket import java.net.SocketTimeoutException +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore import java.security.cert.CertPathValidatorException +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Date +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.SSLServerSocketFactory +import javax.net.ssl.TrustManagerFactory +import javax.security.auth.x500.X500Principal import kotlin.concurrent.thread import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days private const val REGTEST_GENESIS = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" private const val MAINNET_GENESIS = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" +private const val VERSION_REPLY = """{"id":0,"jsonrpc":"2.0","result":["fake-electrs","1.4"]}""" + +private fun featuresReplyWith(genesisHash: String) = + """{"id":1,"jsonrpc":"2.0","result":{"genesis_hash":"$genesisHash"}}""" + +private const val LOOPBACK = "127.0.0.1" +private const val RSA_KEY_SIZE = 2048 +private val CERTIFICATE_VALIDITY = 1.days +private val KEY_PASSWORD = "probe".toCharArray() + @OptIn(ExperimentalCoroutinesApi::class) class ElectrumProbeServiceTest : BaseUnitTest() { private val sut = ElectrumProbeService(ioDispatcher = Dispatchers.IO) private var server: ServerSocket? = null + private var defaultSslContext: SSLContext? = null + @After fun tearDown() { server?.runCatching { close() } server = null + defaultSslContext?.let { SSLContext.setDefault(it) } + defaultSslContext = null } @Test @@ -181,6 +214,30 @@ class ElectrumProbeServiceTest : BaseUnitTest() { assertIs(result.exceptionOrNull()) } + // Regression: TLS to a host named by an IP or an alias, answered by a server whose certificate + // chain is valid but was issued for another name. A raw SSLSocket checks the chain and not the + // name, so this probed clean and only the node's own electrum client rejected it — after the + // node restart the probe exists to avoid. + @Test + fun `probe rejects a certificate issued for another host`() = test { + val port = startTlsElectrum(certificateFor = GeneralName(GeneralName.dNSName, "wrong.example")) + + val result = sut.probe(serverAt(port, ElectrumProtocol.SSL), network = Network.REGTEST) + + assertIs(result.exceptionOrNull()) + } + + // The other half of the pair: verifying the name must not reject a certificate that does name + // the host, otherwise the probe would refuse servers the node itself accepts. + @Test + fun `probe accepts a certificate issued for the host it connected to`() = test { + val port = startTlsElectrum(certificateFor = GeneralName(GeneralName.iPAddress, LOOPBACK)) + + val result = sut.probe(serverAt(port, ElectrumProtocol.SSL), network = Network.REGTEST) + + assertTrue(result.isSuccess) + } + @Test fun `probe reports the requested server in its error`() = test { val port = startSilentServer() @@ -254,7 +311,7 @@ class ElectrumProbeServiceTest : BaseUnitTest() { } private fun serverAt(port: Int, protocol: ElectrumProtocol = ElectrumProtocol.TCP) = ElectrumServer( - host = "127.0.0.1", + host = LOOPBACK, tcp = port, ssl = port, protocol = protocol, @@ -266,12 +323,12 @@ class ElectrumProbeServiceTest : BaseUnitTest() { */ private fun startFakeElectrum( genesisHash: String? = null, - versionReply: String = """{"id":0,"jsonrpc":"2.0","result":["fake-electrs","1.4"]}""", + versionReply: String = VERSION_REPLY, featuresReply: String = genesisHash - ?.let { """{"id":1,"jsonrpc":"2.0","result":{"genesis_hash":"$it"}}""" } + ?.let { featuresReplyWith(it) } ?: """{"id":1,"error":{"code":-32601,"message":"unknown method"}}""", ): Int { - val socket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).also { server = it } + val socket = ServerSocket(0, 1, InetAddress.getByName(LOOPBACK)).also { server = it } thread(isDaemon = true) { runCatching { socket.accept().use { client -> serveElectrum(client, versionReply, featuresReply) } @@ -299,7 +356,7 @@ class ElectrumProbeServiceTest : BaseUnitTest() { } private fun startOversizedLineServer(): Int { - val socket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).also { server = it } + val socket = ServerSocket(0, 1, InetAddress.getByName(LOOPBACK)).also { server = it } thread(isDaemon = true) { runCatching { socket.accept().use { client -> @@ -315,9 +372,75 @@ class ElectrumProbeServiceTest : BaseUnitTest() { return socket.localPort } + /** + * Serves electrum over TLS behind a self-signed certificate naming [certificateFor], made the + * only certificate the JVM trusts. The chain is therefore valid and only the name can fail, so + * what the probe reports is decided by whether it asks JSSE to check the name at all. + */ + private fun startTlsElectrum(certificateFor: GeneralName, genesisHash: String = REGTEST_GENESIS): Int { + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(RSA_KEY_SIZE) }.generateKeyPair() + val certificate = selfSignedCertificate(keyPair, certificateFor) + trustOnly(certificate) + + val socket = tlsServerFactory(keyPair, certificate) + .createServerSocket(0, 1, InetAddress.getByName(LOOPBACK)) + .also { server = it } + thread(isDaemon = true) { + runCatching { + socket.accept().use { client -> + serveElectrum(client, VERSION_REPLY, featuresReplyWith(genesisHash)) + } + } + } + return socket.localPort + } + + @Suppress("DEPRECATION") // bcprov carries no other certificate generator, and bcpkix is not a dependency + private fun selfSignedCertificate(keyPair: KeyPair, name: GeneralName): X509Certificate { + val generated = X509V3CertificateGenerator().apply { + setSerialNumber(BigInteger.ONE) + setIssuerDN(X500Principal("CN=electrum-probe-test")) + setSubjectDN(X500Principal("CN=electrum-probe-test")) + setNotBefore(Date(nowMillis() - CERTIFICATE_VALIDITY.inWholeMilliseconds)) + setNotAfter(Date(nowMillis() + CERTIFICATE_VALIDITY.inWholeMilliseconds)) + setPublicKey(keyPair.public) + setSignatureAlgorithm("SHA256withRSA") + addExtension(Extension.subjectAlternativeName, false, GeneralNames(name)) + }.generate(keyPair.private) + + // Re-read through the platform factory so the certificate exposes a usable public key. + return CertificateFactory.getInstance("X.509") + .generateCertificate(generated.encoded.inputStream()) as X509Certificate + } + + private fun trustOnly(certificate: X509Certificate) { + val store = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setCertificateEntry("probe", certificate) + } + val trustManagers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + .apply { init(store) } + .trustManagers + + if (defaultSslContext == null) defaultSslContext = SSLContext.getDefault() + SSLContext.setDefault(SSLContext.getInstance("TLS").apply { init(null, trustManagers, null) }) + } + + private fun tlsServerFactory(keyPair: KeyPair, certificate: X509Certificate): SSLServerSocketFactory { + val store = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setKeyEntry("probe", keyPair.private, KEY_PASSWORD, arrayOf(certificate)) + } + val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + .apply { init(store, KEY_PASSWORD) } + .keyManagers + + return SSLContext.getInstance("TLS").apply { init(keyManagers, null, null) }.serverSocketFactory + } + /** Accepts the connection but never speaks electrum, like a non-electrum service on the port. */ private fun startSilentServer(): Int { - val socket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).also { server = it } + val socket = ServerSocket(0, 1, InetAddress.getByName(LOOPBACK)).also { server = it } thread(isDaemon = true) { runCatching { socket.accept().use { it.getInputStream().read() } } } diff --git a/changelog.d/next/1177.fixed.md b/changelog.d/next/1177.fixed.md index 49c24fcf33..0d48affa73 100644 --- a/changelog.d/next/1177.fixed.md +++ b/changelog.d/next/1177.fixed.md @@ -1 +1 @@ -Connecting to a custom Electrum server now explains when it is on the wrong Bitcoin network or when the TCP/TLS protocol does not match the port. +Connecting to a custom Electrum server now explains when it is on the wrong Bitcoin network, when the TCP/TLS protocol does not match the port, or when its TLS certificate is untrusted or issued for another host. From f9ce27047606849236782a0c26f1460f2640b891 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:44:11 -0300 Subject: [PATCH 5/8] test: make the electrum probe tls tests provider-independent Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/to/bitkit/di/HttpModule.kt | 5 ++ .../bitkit/services/ElectrumProbeService.kt | 4 +- .../services/ElectrumProbeServiceTest.kt | 64 ++++++++++--------- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/to/bitkit/di/HttpModule.kt b/app/src/main/java/to/bitkit/di/HttpModule.kt index 4a1f551c6e..63204787e1 100644 --- a/app/src/main/java/to/bitkit/di/HttpModule.kt +++ b/app/src/main/java/to/bitkit/di/HttpModule.kt @@ -22,6 +22,7 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.UrlValidator import javax.inject.Singleton +import javax.net.ssl.SSLSocketFactory import io.ktor.client.plugins.logging.Logger as KtorLogger @Module @@ -47,6 +48,10 @@ object HttpModule { } } + @Provides + @Singleton + fun provideSslSocketFactory(): SSLSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory + @Provides @Singleton fun provideUrlValidator(httpClient: HttpClient) = UrlValidator { url -> diff --git a/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt b/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt index d348d13628..3e1e5d456b 100644 --- a/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt +++ b/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt @@ -41,6 +41,7 @@ import kotlin.time.Duration.Companion.seconds @Singleton class ElectrumProbeService @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val sslSocketFactory: SSLSocketFactory, ) { companion object { private const val TAG = "ElectrumProbeService" @@ -116,8 +117,7 @@ class ElectrumProbeService @Inject constructor( // A TLS handshake against a plain-TCP server hangs without a read timeout, which is the // misconfiguration that wedges the node's release when it is left to node.start(). return runCatching { - val factory = SSLSocketFactory.getDefault() as SSLSocketFactory - val ssl = factory.createSocket(plain, server.host, server.getPort(), true) as SSLSocket + val ssl = sslSocketFactory.createSocket(plain, server.host, server.getPort(), true) as SSLSocket ssl.soTimeout = CONNECT_TIMEOUT.inWholeMilliseconds.toInt() // A raw SSLSocket validates the chain but not the name the certificate was issued for, so // a CA-valid certificate for another host probes clean and is only rejected afterwards by diff --git a/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt b/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt index 1bd254aed0..6660c8abbc 100644 --- a/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt @@ -38,7 +38,7 @@ import javax.net.ssl.SSLContext import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException -import javax.net.ssl.SSLServerSocketFactory +import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManagerFactory import javax.security.auth.x500.X500Principal import kotlin.concurrent.thread @@ -60,20 +60,29 @@ private const val RSA_KEY_SIZE = 2048 private val CERTIFICATE_VALIDITY = 1.days private val KEY_PASSWORD = "probe".toCharArray() +/** + * The JDK's own JSSE provider, named rather than taken from the head of the provider list. + * + * Robolectric installs Conscrypt as the JVM's first security provider and never removes it, so every + * test class running after one of those would otherwise get Conscrypt here. A Conscrypt server socket + * cannot finish a handshake on a current JDK — it reflects into `java.net`, which the module system + * refuses — which made these TLS tests depend on which class ran before them. + */ +private const val JSSE_PROVIDER = "SunJSSE" + @OptIn(ExperimentalCoroutinesApi::class) class ElectrumProbeServiceTest : BaseUnitTest() { - private val sut = ElectrumProbeService(ioDispatcher = Dispatchers.IO) + private var socketFactory: SSLSocketFactory = + SSLContext.getInstance("TLS", JSSE_PROVIDER).apply { init(null, null, null) }.socketFactory - private var server: ServerSocket? = null + private val sut get() = ElectrumProbeService(ioDispatcher = Dispatchers.IO, sslSocketFactory = socketFactory) - private var defaultSslContext: SSLContext? = null + private var server: ServerSocket? = null @After fun tearDown() { server?.runCatching { close() } server = null - defaultSslContext?.let { SSLContext.setDefault(it) } - defaultSslContext = null } @Test @@ -235,7 +244,7 @@ class ElectrumProbeServiceTest : BaseUnitTest() { val result = sut.probe(serverAt(port, ElectrumProtocol.SSL), network = Network.REGTEST) - assertTrue(result.isSuccess) + assertTrue(result.isSuccess, "probe rejected a matching certificate: '${result.exceptionOrNull()}'") } @Test @@ -373,16 +382,17 @@ class ElectrumProbeServiceTest : BaseUnitTest() { } /** - * Serves electrum over TLS behind a self-signed certificate naming [certificateFor], made the - * only certificate the JVM trusts. The chain is therefore valid and only the name can fail, so - * what the probe reports is decided by whether it asks JSSE to check the name at all. + * Serves electrum over TLS behind a self-signed certificate naming [certificateFor], the only + * certificate the probe is given to trust. The chain is therefore valid and only the name can + * fail, so what the probe reports is decided by whether it asks JSSE to check the name at all. */ private fun startTlsElectrum(certificateFor: GeneralName, genesisHash: String = REGTEST_GENESIS): Int { val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(RSA_KEY_SIZE) }.generateKeyPair() val certificate = selfSignedCertificate(keyPair, certificateFor) - trustOnly(certificate) + val tls = tlsContext(keyPair, certificate) + socketFactory = tls.socketFactory - val socket = tlsServerFactory(keyPair, certificate) + val socket = tls.serverSocketFactory .createServerSocket(0, 1, InetAddress.getByName(LOOPBACK)) .also { server = it } thread(isDaemon = true) { @@ -413,29 +423,25 @@ class ElectrumProbeServiceTest : BaseUnitTest() { .generateCertificate(generated.encoded.inputStream()) as X509Certificate } - private fun trustOnly(certificate: X509Certificate) { - val store = KeyStore.getInstance("PKCS12").apply { + /** Serves [certificate] to the probe and is the only certificate the probe is told to trust. */ + private fun tlsContext(keyPair: KeyPair, certificate: X509Certificate): SSLContext { + val keyStore = KeyStore.getInstance("PKCS12").apply { load(null, null) - setCertificateEntry("probe", certificate) + setKeyEntry("probe", keyPair.private, KEY_PASSWORD, arrayOf(certificate)) } - val trustManagers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - .apply { init(store) } - .trustManagers - - if (defaultSslContext == null) defaultSslContext = SSLContext.getDefault() - SSLContext.setDefault(SSLContext.getInstance("TLS").apply { init(null, trustManagers, null) }) - } + val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm(), JSSE_PROVIDER) + .apply { init(keyStore, KEY_PASSWORD) } + .keyManagers - private fun tlsServerFactory(keyPair: KeyPair, certificate: X509Certificate): SSLServerSocketFactory { - val store = KeyStore.getInstance("PKCS12").apply { + val trustStore = KeyStore.getInstance("PKCS12").apply { load(null, null) - setKeyEntry("probe", keyPair.private, KEY_PASSWORD, arrayOf(certificate)) + setCertificateEntry("probe", certificate) } - val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) - .apply { init(store, KEY_PASSWORD) } - .keyManagers + val trustManagers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm(), JSSE_PROVIDER) + .apply { init(trustStore) } + .trustManagers - return SSLContext.getInstance("TLS").apply { init(keyManagers, null, null) }.serverSocketFactory + return SSLContext.getInstance("TLS", JSSE_PROVIDER).apply { init(keyManagers, trustManagers, null) } } /** Accepts the connection but never speaks electrum, like a non-electrum service on the port. */ From 3c03cfc3a6e65ad9cbd8de786160b6055051bb07 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:47:47 -0300 Subject: [PATCH 6/8] docs: add electrum server error toasts journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 + .../settings/electrum-server-error-toasts.xml | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 journeys/settings/electrum-server-error-toasts.xml diff --git a/journeys/README.md b/journeys/README.md index 403c5e2309..04ded65a27 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -121,6 +121,7 @@ fixtures, push notifications) live in each suite's README. | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [payment-requests](payment-requests) | 2 | Requires a linked fixture issuer; rejected shapes are unit fixtures | | [pubky-marketplace](pubky-marketplace) | 1 | Two-wallet Paykit marketplace payment; integration fixture required | +| [settings](settings) | 1 | Electrum server error toasts; no README | | [widgets](widgets) | 2 | Needs no backend — the quickest way to see the loop work; no README | ## Cross-platform @@ -141,6 +142,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/usb-reconnect.xml` | `reconnect.xml` — over Bridge, since iOS cannot do WebUSB | | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | +| `settings/electrum-server-error-toasts.xml` | not ported — iOS still shows one generic message for every manual Electrum connect failure | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | diff --git a/journeys/settings/electrum-server-error-toasts.xml b/journeys/settings/electrum-server-error-toasts.xml new file mode 100644 index 0000000000..e99e337a99 --- /dev/null +++ b/journeys/settings/electrum-server-error-toasts.xml @@ -0,0 +1,37 @@ + + + Verifies that a rejected custom Electrum server shows a warning toast whose description names the + cause: a wrong-network server and a TLS handshake against a plain TCP port get their own messages, + and an unreachable host keeps the generic one. The server is probed before the node stops, so a + rejected server is never persisted and the wallet stays on its current server. + + Precondition: onboarded dev wallet on the default staging regtest server (Settings shows Electrum + Server "Auto"). Start on the wallet home screen. Toasts never appear in `android layout` and last + about 2s, so assert them from a screen recording or a screenshot taken 2-6s after tapping connect. + Type hosts in short chunks and verify the field text; `adb shell input text` drops characters. + Do not tap "Reset To Default" unless the starting server was the default. + + + Tap the menu icon (testTag "HeaderMenu") + Tap Settings (testTag "DrawerSettings") + Tap the Advanced tab (testTag "Tab-advanced") + Tap Electrum Server (testTag "ElectrumConfig") + Verify "Currently connected to" is visible and note the connected server (testTag "Connected") + Replace the host (testTag "HostInput") with "electrum.blockstream.info" and the port (testTag "PortInput") with "50002" + Select "TLS" under protocol (testTag "ElectrumProtocol") + Hide the keyboard and tap Connect To Host (testTag "ConnectToHost") + Verify a warning toast "Electrum Connection Failed" appears with the description "This server is on a different Bitcoin network. Choose a server for the network Bitkit is using." + Verify the connected server (testTag "Connected") is unchanged + Replace the port (testTag "PortInput") with "50001", keeping "TLS" selected + Hide the keyboard and tap Connect To Host (testTag "ConnectToHost") + Verify a warning toast "Electrum Connection Failed" appears with the description "Secure connection failed. Check that the protocol (TCP or TLS) matches the server port." + Replace the host (testTag "HostInput") with "10.255.255.1" and the port (testTag "PortInput") with "9999" + Hide the keyboard and tap Connect To Host (testTag "ConnectToHost") + Verify a warning toast "Electrum Connection Failed" appears with the description "Bitkit could not establish a connection to Electrum." after the ~5s connect timeout + Verify the connected server (testTag "Connected") is unchanged + Tap Reset To Default (testTag "ResetToDefault") + Verify the host and port fields show the default server, which is the connected one, and Connect To Host (testTag "ConnectToHost") is disabled + Tap back (testTag "NavigationBack") + Verify the Electrum Server row (testTag "ElectrumConfig") still shows "Auto" + + From 8f00d51b2aaa82e0e8c572a508ada186dd7deef9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 11:56:34 -0300 Subject: [PATCH 7/8] docs: set electrum protocol before port in journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/settings/electrum-server-error-toasts.xml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/journeys/settings/electrum-server-error-toasts.xml b/journeys/settings/electrum-server-error-toasts.xml index e99e337a99..eb3431ba3c 100644 --- a/journeys/settings/electrum-server-error-toasts.xml +++ b/journeys/settings/electrum-server-error-toasts.xml @@ -9,7 +9,10 @@ Server "Auto"). Start on the wallet home screen. Toasts never appear in `android layout` and last about 2s, so assert them from a screen recording or a screenshot taken 2-6s after tapping connect. Type hosts in short chunks and verify the field text; `adb shell input text` drops characters. - Do not tap "Reset To Default" unless the starting server was the default. + Do not tap "Reset To Default" unless the starting server was the default. Tapping a protocol row + rewrites the port whenever it is empty or still one of 51002/50002/51001/50001, to that + protocol's default for the build's network (60002 for TLS on the regtest dev build), so always + set the protocol before typing the port and never re-tap a protocol row afterwards. Tap the menu icon (testTag "HeaderMenu") @@ -17,12 +20,12 @@ Tap the Advanced tab (testTag "Tab-advanced") Tap Electrum Server (testTag "ElectrumConfig") Verify "Currently connected to" is visible and note the connected server (testTag "Connected") - Replace the host (testTag "HostInput") with "electrum.blockstream.info" and the port (testTag "PortInput") with "50002" Select "TLS" under protocol (testTag "ElectrumProtocol") + Replace the host (testTag "HostInput") with "electrum.blockstream.info" and the port (testTag "PortInput") with "50002", then verify the port field still reads "50002" Hide the keyboard and tap Connect To Host (testTag "ConnectToHost") Verify a warning toast "Electrum Connection Failed" appears with the description "This server is on a different Bitcoin network. Choose a server for the network Bitkit is using." Verify the connected server (testTag "Connected") is unchanged - Replace the port (testTag "PortInput") with "50001", keeping "TLS" selected + Replace the port (testTag "PortInput") with "50001" without tapping a protocol row, keeping "TLS" selected, and verify the port field still reads "50001" Hide the keyboard and tap Connect To Host (testTag "ConnectToHost") Verify a warning toast "Electrum Connection Failed" appears with the description "Secure connection failed. Check that the protocol (TCP or TLS) matches the server port." Replace the host (testTag "HostInput") with "10.255.255.1" and the port (testTag "PortInput") with "9999" From a1cd640d5295b19add2d37cac0e0e30e29db2e01 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 21 Sep 2026 08:24:27 -0300 Subject: [PATCH 8/8] docs: restore journey suite table order after merge Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/README.md b/journeys/README.md index ac8c9fcabf..327a6e8c04 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -129,8 +129,8 @@ fixtures, push notifications) live in each suite's README. | [receive](receive) | 1 | Receive sheet tab selection; needs a spending channel, no README | | [restore-wallet](restore-wallet) | 1 | Pasting a seed fragment on Restore wallet; needs a wallet-free device; no README | | [security](security) | 1 | PIN result sheet layout at a long locale and font scale; no README | -| [shop](shop) | 1 | Shop Discover category titles and web view handoff; needs Bitrefill reachable; no README | | [settings](settings) | 1 | Electrum server error toasts; no README | +| [shop](shop) | 1 | Shop Discover category titles and web view handoff; needs Bitrefill reachable; no README | | [subscriptions](subscriptions) | 4 | Paykit subscription lifecycle across two wallets, plus the Payments tab | | [tags](tags) | 1 | Tag input length cap on an activity; no backend, no README | | [transfers](transfers) | 1 | Transfer to Spending settling after the LSP closes the channel; no README |