Skip to content

Commit fd1f24b

Browse files
authored
Merge pull request #880 from synonymdev/fix/rgs-crash
fix: crash when changing rgs server
2 parents 7bc1869 + 06c2835 commit fd1f24b

8 files changed

Lines changed: 451 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Fixed
11+
- Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880
1112
- Fix crash when returning app to foreground on Receive screen #875
1213
- Show loading state on Spending tab when node is not running #875
1314

app/src/main/java/to/bitkit/di/HttpModule.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@ import io.ktor.client.plugins.defaultRequest
1212
import io.ktor.client.plugins.logging.LogLevel
1313
import io.ktor.client.plugins.logging.Logging
1414
import io.ktor.client.plugins.logging.LoggingConfig
15+
import io.ktor.client.request.head
1516
import io.ktor.http.ContentType
1617
import io.ktor.http.contentType
18+
import io.ktor.http.isSuccess
1719
import io.ktor.serialization.kotlinx.json.json
1820
import kotlinx.serialization.json.Json
21+
import to.bitkit.utils.UrlValidator
22+
import to.bitkit.utils.AppError
1923
import to.bitkit.utils.Logger
2024
import javax.inject.Singleton
2125
import io.ktor.client.plugins.logging.Logger as KtorLogger
@@ -43,6 +47,17 @@ object HttpModule {
4347
}
4448
}
4549

50+
@Provides
51+
@Singleton
52+
fun provideUrlValidator(httpClient: HttpClient) = UrlValidator { url ->
53+
runCatching {
54+
val response = httpClient.head(url)
55+
if (!response.status.isSuccess()) {
56+
throw AppError("Server returned '${response.status}'")
57+
}
58+
}
59+
}
60+
4661
@Suppress("MagicNumber")
4762
private fun HttpTimeoutConfig.defaultTimeoutConfig() {
4863
requestTimeoutMillis = 60_000

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import to.bitkit.services.NodeEventHandler
7979
import to.bitkit.utils.AppError
8080
import to.bitkit.utils.Logger
8181
import to.bitkit.utils.ServiceError
82+
import to.bitkit.utils.UrlValidator
8283
import java.io.File
8384
import java.util.concurrent.ConcurrentHashMap
8485
import java.util.concurrent.atomic.AtomicBoolean
@@ -105,6 +106,7 @@ class LightningRepo @Inject constructor(
105106
private val preActivityMetadataRepo: PreActivityMetadataRepo,
106107
private val connectivityRepo: ConnectivityRepo,
107108
private val vssBackupClientLdk: VssBackupClientLdk,
109+
private val urlValidator: UrlValidator,
108110
) {
109111
private val _lightningState = MutableStateFlow(LightningState())
110112
val lightningState = _lightningState.asStateFlow()
@@ -619,6 +621,11 @@ class LightningRepo @Inject constructor(
619621
suspend fun restartWithRgsServer(newRgsUrl: String): Result<Unit> = withContext(bgDispatcher) {
620622
Logger.info("Changing ldk-node RGS server to: '$newRgsUrl'", context = TAG)
621623

624+
validateRgsUrl(newRgsUrl).onFailure {
625+
Logger.warn("RGS server unreachable at '$newRgsUrl'", it, context = TAG)
626+
return@withContext Result.failure(it)
627+
}
628+
622629
waitForNodeToStop().onFailure { return@withContext Result.failure(it) }
623630
stop().onFailure {
624631
Logger.error("Failed to stop node during RGS server change", it, context = TAG)
@@ -640,6 +647,12 @@ class LightningRepo @Inject constructor(
640647
}
641648
}
642649

650+
private suspend fun validateRgsUrl(url: String): Result<Unit> = withContext(bgDispatcher) {
651+
val initialTimestamp = 0
652+
val testUrl = "${url.trimEnd('/')}/$initialTimestamp"
653+
urlValidator.validate(testUrl)
654+
}
655+
643656
suspend fun getBalanceForAddressType(addressType: AddressType): Result<ULong> = withContext(bgDispatcher) {
644657
executeWhenNodeRunning("getBalanceForAddressType") {
645658
runCatching {

app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerViewModel.kt

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package to.bitkit.ui.settings.advanced
22

3+
import androidx.compose.runtime.Stable
34
import androidx.lifecycle.ViewModel
45
import androidx.lifecycle.viewModelScope
56
import dagger.hilt.android.lifecycle.HiltViewModel
67
import kotlinx.coroutines.CoroutineDispatcher
8+
import kotlinx.coroutines.Job
9+
import kotlinx.coroutines.delay
710
import kotlinx.coroutines.flow.MutableStateFlow
811
import kotlinx.coroutines.flow.StateFlow
912
import kotlinx.coroutines.flow.asStateFlow
@@ -15,7 +18,9 @@ import to.bitkit.data.SettingsStore
1518
import to.bitkit.di.BgDispatcher
1619
import to.bitkit.env.Env
1720
import to.bitkit.repositories.LightningRepo
21+
import java.net.URI
1822
import javax.inject.Inject
23+
import kotlin.time.Duration.Companion.seconds
1924

2025
@HiltViewModel
2126
class RgsServerViewModel @Inject constructor(
@@ -24,9 +29,20 @@ class RgsServerViewModel @Inject constructor(
2429
private val lightningRepo: LightningRepo,
2530
) : ViewModel() {
2631

32+
companion object {
33+
private val HOSTNAME_PATTERN = Regex(
34+
"^([a-z\\d]([a-z\\d-]*[a-z\\d])*\\.)+[a-z]{2,}|(\\d{1,3}\\.){3}\\d{1,3}$",
35+
RegexOption.IGNORE_CASE,
36+
)
37+
private val PATH_PATTERN = Regex("^(/[a-zA-Z\\d_.~%+-]*)*$")
38+
private val VALIDATION_DEBOUNCE = 1.seconds
39+
}
40+
2741
private val _uiState = MutableStateFlow(RgsServerUiState())
2842
val uiState: StateFlow<RgsServerUiState> = _uiState.asStateFlow()
2943

44+
private var validationJob: Job? = null
45+
3046
init {
3147
observeState()
3248
}
@@ -47,17 +63,20 @@ class RgsServerViewModel @Inject constructor(
4763
}
4864

4965
fun setRgsUrl(url: String) {
50-
_uiState.update {
51-
val newState = it.copy(rgsUrl = url.trim())
52-
computeState(newState)
53-
}
66+
_uiState.update { it.copy(rgsUrl = url.trim()) }
67+
debounceValidation()
5468
}
5569

5670
fun resetToDefault() {
57-
val defaultUrl = Env.ldkRgsServerUrl ?: ""
58-
_uiState.update {
59-
val newState = it.copy(rgsUrl = defaultUrl)
60-
computeState(newState)
71+
_uiState.update { it.copy(rgsUrl = Env.ldkRgsServerUrl ?: "") }
72+
debounceValidation()
73+
}
74+
75+
private fun debounceValidation() {
76+
validationJob?.cancel()
77+
validationJob = viewModelScope.launch(bgDispatcher) {
78+
delay(VALIDATION_DEBOUNCE)
79+
_uiState.update { computeState(it) }
6180
}
6281
}
6382

@@ -72,7 +91,7 @@ class RgsServerViewModel @Inject constructor(
7291
_uiState.update { it.copy(isLoading = true) }
7392

7493
viewModelScope.launch(bgDispatcher) {
75-
lightningRepo.restartWithRgsServer(url)
94+
lightningRepo.restartWithRgsServer(normalizeUrl(url))
7695
.onSuccess {
7796
_uiState.update {
7897
val newState = it.copy(
@@ -109,24 +128,25 @@ class RgsServerViewModel @Inject constructor(
109128
)
110129
}
111130

131+
private fun normalizeUrl(url: String): String =
132+
if (!url.startsWith("http://") && !url.startsWith("https://")) "https://$url" else url
133+
112134
private fun isValidURL(data: String): Boolean {
113-
val pattern = Regex(
114-
"^(https?://)?" + // protocol
115-
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
116-
"((\\d{1,3}\\.){3}\\d{1,3}))" + // IP (v4) address
117-
"(:\\d+)?(/[-a-z\\d%_.~+]*)*", // port and path
118-
RegexOption.IGNORE_CASE
119-
)
135+
return runCatching {
136+
val uri = URI(normalizeUrl(data))
137+
val hostname = uri.host ?: return false
120138

121-
// Allow localhost in development mode
122-
if (Env.isDebug && data.contains("localhost")) {
123-
return true
124-
}
139+
if (Env.isDebug && hostname == "localhost") return true
140+
141+
if (!HOSTNAME_PATTERN.matches(hostname)) return false
125142

126-
return pattern.matches(data)
143+
val path = uri.path.orEmpty()
144+
path.isEmpty() || PATH_PATTERN.matches(path)
145+
}.getOrDefault(false)
127146
}
128147
}
129148

149+
@Stable
130150
data class RgsServerUiState(
131151
val connectedRgsUrl: String? = null,
132152
val rgsUrl: String = "",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package to.bitkit.utils
2+
3+
fun interface UrlValidator {
4+
suspend fun validate(url: String): Result<Unit>
5+
}

app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -328,53 +328,6 @@ class WalletViewModel @Inject constructor(
328328
}
329329
}
330330

331-
private suspend fun checkForOrphanedChannelMonitorRecovery() {
332-
if (migrationService.isChannelRecoveryChecked()) return
333-
334-
Logger.info("Running one-time channel monitor recovery check", context = TAG)
335-
336-
val allMonitorsRetrieved = runCatching {
337-
val allRetrieved = migrationService.fetchRNRemoteLdkData()
338-
// don't overwrite channel manager, we only need the monitors for the sweep
339-
val channelMigration = buildChannelMigrationIfAvailable()?.let {
340-
ChannelDataMigration(channelManager = null, channelMonitors = it.channelMonitors)
341-
}
342-
343-
if (channelMigration == null) {
344-
Logger.info("No channel monitors found on RN backup", context = TAG)
345-
return@runCatching allRetrieved
346-
}
347-
348-
Logger.info(
349-
"Found ${channelMigration.channelMonitors.size} monitors on RN backup, attempting recovery",
350-
context = TAG,
351-
)
352-
353-
lightningRepo.stop().onFailure {
354-
Logger.error("Failed to stop node for channel recovery", it, context = TAG)
355-
}
356-
delay(CHANNEL_RECOVERY_RESTART_DELAY_MS)
357-
lightningRepo.start(channelMigration = channelMigration, shouldRetry = false)
358-
.onSuccess {
359-
migrationService.consumePendingChannelMigration()
360-
walletRepo.syncNodeAndWallet()
361-
walletRepo.syncBalances()
362-
Logger.info("Channel monitor recovery complete", context = TAG)
363-
}
364-
.onFailure {
365-
Logger.error("Failed to restart node after channel recovery", it, context = TAG)
366-
}
367-
368-
allRetrieved
369-
}.getOrDefault(false)
370-
371-
if (allMonitorsRetrieved) {
372-
migrationService.markChannelRecoveryChecked()
373-
} else {
374-
Logger.warn("Some monitors failed to download, will retry on next startup", context = TAG)
375-
}
376-
}
377-
378331
fun stop() {
379332
if (!walletExists) return
380333

app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import to.bitkit.services.LightningService
5151
import to.bitkit.services.LnurlService
5252
import to.bitkit.services.LspNotificationsService
5353
import to.bitkit.test.BaseUnitTest
54+
import to.bitkit.utils.UrlValidator
5455
import kotlin.test.assertEquals
5556
import kotlin.test.assertFalse
5657
import kotlin.test.assertNotNull
@@ -72,6 +73,7 @@ class LightningRepoTest : BaseUnitTest() {
7273
private val lnurlService = mock<LnurlService>()
7374
private val connectivityRepo = mock<ConnectivityRepo>()
7475
private val vssBackupClientLdk = mock<VssBackupClientLdk>()
76+
private val urlValidator = UrlValidator { Result.success(Unit) }
7577

7678
@Before
7779
fun setUp() = runBlocking {
@@ -94,6 +96,7 @@ class LightningRepoTest : BaseUnitTest() {
9496
preActivityMetadataRepo = preActivityMetadataRepo,
9597
connectivityRepo = connectivityRepo,
9698
vssBackupClientLdk = vssBackupClientLdk,
99+
urlValidator = urlValidator,
97100
)
98101
}
99102

@@ -498,6 +501,78 @@ class LightningRepoTest : BaseUnitTest() {
498501
assertTrue(result.isFailure)
499502
}
500503

504+
@Test
505+
fun `restartWithRgsServer should setup with new rgs server`() = test {
506+
startNodeForTesting()
507+
val customRgsUrl = "https://rgs.example.com/snapshot"
508+
whenever(lightningService.node).thenReturn(null)
509+
whenever(lightningService.stop()).thenReturn(Unit)
510+
511+
val result = sut.restartWithRgsServer(customRgsUrl)
512+
513+
assertTrue(result.isSuccess)
514+
val inOrder = inOrder(lightningService)
515+
inOrder.verify(lightningService).stop()
516+
inOrder.verify(lightningService).setup(any(), isNull(), eq(customRgsUrl), anyOrNull(), anyOrNull())
517+
inOrder.verify(lightningService).start(anyOrNull(), any())
518+
assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState)
519+
}
520+
521+
@Test
522+
fun `restartWithRgsServer should handle stop failure`() = test {
523+
startNodeForTesting()
524+
whenever(lightningService.stop()).thenThrow(RuntimeException("Stop failed"))
525+
526+
val result = sut.restartWithRgsServer("https://rgs.example.com/snapshot")
527+
528+
assertTrue(result.isFailure)
529+
}
530+
531+
@Test
532+
fun `restartWithRgsServer should handle start failure and recover`() = test {
533+
startNodeForTesting()
534+
whenever(lightningService.node).thenReturn(null)
535+
whenever(lightningService.stop()).thenReturn(Unit)
536+
whenever(lightningService.setup(any(), isNull(), eq("https://bad.rgs/snapshot"), anyOrNull(), anyOrNull()))
537+
.thenThrow(RuntimeException("Failed to start node"))
538+
539+
val result = sut.restartWithRgsServer("https://bad.rgs/snapshot")
540+
541+
assertTrue(result.isFailure)
542+
}
543+
544+
@Test
545+
fun `restartWithRgsServer should fail when url is unreachable`() = test {
546+
val failingValidator = UrlValidator { Result.failure(Exception("DNS resolution failed")) }
547+
val sutWithFailingValidator = LightningRepo(
548+
bgDispatcher = testDispatcher,
549+
lightningService = lightningService,
550+
settingsStore = settingsStore,
551+
coreService = coreService,
552+
lspNotificationsService = lspNotificationsService,
553+
firebaseMessaging = firebaseMessaging,
554+
keychain = keychain,
555+
lnurlService = lnurlService,
556+
cacheStore = cacheStore,
557+
preActivityMetadataRepo = preActivityMetadataRepo,
558+
connectivityRepo = connectivityRepo,
559+
vssBackupClientLdk = vssBackupClientLdk,
560+
urlValidator = failingValidator,
561+
)
562+
sutWithFailingValidator.setInitNodeLifecycleState()
563+
whenever(lightningService.node).thenReturn(mock())
564+
whenever(lightningService.sync()).thenReturn(Unit)
565+
val blocktank = mock<BlocktankService>()
566+
whenever(coreService.blocktank).thenReturn(blocktank)
567+
whenever(blocktank.info(any())).thenReturn(null)
568+
sutWithFailingValidator.start()
569+
570+
val result = sutWithFailingValidator.restartWithRgsServer("https://rapidsync.lightningdevkit/snapshot")
571+
572+
assertTrue(result.isFailure)
573+
assertEquals("DNS resolution failed", result.exceptionOrNull()?.message)
574+
}
575+
501576
@Test
502577
fun `getFeeRateForSpeed should use provided feeRates`() = test {
503578
val mockFeeRates = mock<FeeRates>()

0 commit comments

Comments
 (0)