From fa29acb05575e78b549657329757fc3bfa990ae0 Mon Sep 17 00:00:00 2001 From: finslis-core Date: Sat, 2 May 2026 17:00:02 +0400 Subject: [PATCH] fix(android): stabilize lightserver connectivity, endpoint routing, and send/read diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalize endpoint input to explorer base URL and derive RPC URL internally - migrate / inputs to explorer - strip accidental route inputs like - enforce correct surface split - explorer reads via - broadcast via - fix balance semantics in app UI - trust for displayed spendable balance - keep UTXO list for coin selection/send flow - add strict UTXO parsing policy - accept - reject entries missing both instead of defaulting to zero - improve endpoint health + diagnostics - add malformed-data classification - include resolved explorer URL, resolved RPC URL, and raw transport error - add per-endpoint probe diagnostics in saved endpoint cards - prioritize active-endpoint failure visibility over fallback noise - improve resilience/performance - add short TTL caching + stale-if-error fallback for explorer reads - avoid redundant same-cycle address/status fetches - increase explorer HTTP connect/read timeouts to 15s - fix stale UI state behavior - clear sticky endpoint-status messages correctly - preserve last good dashboard state on transient unavailable reads - align labels/UX - rename “Finalized balance” -> “Spendable balance” - update endpoint input copy to avoid confusion - housekeeping - remove deprecated manifest attributes in feature/data modules - update tests for endpoint normalization/fallback and diagnostics wiring --- android/app/build.gradle.kts | 4 +- .../finalis/mobile/app/DiagnosticsSection.kt | 59 ++++- .../mobile/app/RpcSettingsRepository.kt | 62 +++-- .../com/finalis/mobile/app/SendSection.kt | 2 +- .../finalis/mobile/app/WalletHomeSection.kt | 4 +- .../com/finalis/mobile/app/WalletState.kt | 31 ++- .../com/finalis/mobile/app/WalletViewModel.kt | 40 +++- .../app/RuntimeLightserverRepositoryTest.kt | 49 ++-- .../data/lightserver/ExplorerRepository.kt | 221 ++++++++++++++++-- .../data/lightserver/LightserverRepository.kt | 7 + .../data/wallet/src/main/AndroidManifest.xml | 2 +- .../history/src/main/AndroidManifest.xml | 2 +- .../feature/home/src/main/AndroidManifest.xml | 2 +- .../onboarding/src/main/AndroidManifest.xml | 2 +- .../receive/src/main/AndroidManifest.xml | 2 +- .../feature/send/src/main/AndroidManifest.xml | 2 +- .../settings/src/main/AndroidManifest.xml | 2 +- 17 files changed, 410 insertions(+), 83 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a645a1e..7692203 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -34,7 +34,9 @@ android { manifestPlaceholders["usesCleartextTraffic"] = "true" } release { - manifestPlaceholders["usesCleartextTraffic"] = "false" + // Mainnet operators currently expose HTTP endpoints on :18080/:19444. + // Keep release cleartext-enabled until HTTPS endpoints are mandatory. + manifestPlaceholders["usesCleartextTraffic"] = "true" isMinifyEnabled = false isShrinkResources = false } diff --git a/android/app/src/main/java/com/finalis/mobile/app/DiagnosticsSection.kt b/android/app/src/main/java/com/finalis/mobile/app/DiagnosticsSection.kt index 80fc6ef..7abfd96 100644 --- a/android/app/src/main/java/com/finalis/mobile/app/DiagnosticsSection.kt +++ b/android/app/src/main/java/com/finalis/mobile/app/DiagnosticsSection.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.finalis.mobile.core.wallet.FinalisMainnet import com.finalis.mobile.core.wallet.WalletSendService +import com.finalis.mobile.data.lightserver.rpcUrlFromExplorerUrl @Composable fun EndpointHealthBanner( @@ -76,6 +77,7 @@ fun DiagnosticsSection( EndpointSettingsBlock( rpcSettingsState = rpcSettingsState, endpointHealth = endpointHealth, + endpointProbes = diagnosticsState.endpointProbes, onRpcEndpointInputChange = onRpcEndpointInputChange, onAddEndpoint = onAddEndpoint, onSelectEndpoint = onSelectEndpoint, @@ -100,6 +102,9 @@ fun DiagnosticsSection( fallbackReason = status.checkpointFallbackReason, fallbackSticky = status.fallbackSticky, adaptiveSummary = buildAdaptiveSummary(status), + utxoDiagnostics = diagnosticsState.utxoDiagnostics?.let { snapshot -> + "total ${snapshot.totalReturned}, finalized-kept ${snapshot.finalizedKept}, filtered-pending ${snapshot.filteredPending}" + }, ) } if (diagnosticsState.txDebugRecords.isNotEmpty()) { @@ -151,6 +156,27 @@ private fun EndpointHealthSummary( monospace = true, ) } + endpointHealth.resolvedExplorerUrl?.let { + LabelValue( + label = "Resolved explorer URL", + value = it, + monospace = true, + ) + } + endpointHealth.resolvedRpcUrl?.let { + LabelValue( + label = "Resolved RPC URL", + value = it, + monospace = true, + ) + } + endpointHealth.errorMessage?.let { + LabelValue( + label = "Raw transport error", + value = it, + monospace = true, + ) + } endpointHealth.status?.let { status -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { StatusChip(text = "Height ${(status.finalizedHeight ?: status.tipHeight)}") @@ -170,6 +196,7 @@ private fun AdvancedConnectionBlock( fallbackReason: String?, fallbackSticky: Boolean?, adaptiveSummary: String?, + utxoDiagnostics: String?, ) { var expanded by remember { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -196,6 +223,7 @@ private fun AdvancedConnectionBlock( fallbackReason?.let { LabelValue(label = "Fallback reason", value = it) } fallbackSticky?.let { LabelValue(label = "Sticky fallback", value = it.toString()) } adaptiveSummary?.let { LabelValue(label = "Adaptive regime", value = it) } + utxoDiagnostics?.let { LabelValue(label = "UTXO ingest", value = it) } } } } @@ -293,6 +321,7 @@ private fun abbreviateDebugTxid(txid: String): String = private fun EndpointSettingsBlock( rpcSettingsState: RpcSettingsState, endpointHealth: EndpointHealthState, + endpointProbes: List, onRpcEndpointInputChange: (String) -> Unit, onAddEndpoint: () -> Unit, onSelectEndpoint: (RpcEndpoint) -> Unit, @@ -315,8 +344,8 @@ private fun EndpointSettingsBlock( value = rpcSettingsState.inputValue, onValueChange = onRpcEndpointInputChange, modifier = Modifier.fillMaxWidth(), - label = { Text("Add RPC endpoint") }, - placeholder = { Text("https://lightserver.example.com/rpc") }, + label = { Text("Add endpoint URL") }, + placeholder = { Text("http://lightserver.example.com:18080") }, singleLine = true, ) @@ -328,10 +357,12 @@ private fun EndpointSettingsBlock( EmptyHint("No runtime endpoints are saved yet. The build default endpoint will be used until one is added.") } else { rpcSettingsState.savedEndpoints.forEach { endpoint -> + val endpointProbe = endpointProbes.firstOrNull { it.endpoint.url == endpoint.url } RpcEndpointRow( endpoint = endpoint, isActive = endpoint.url == rpcSettingsState.activeEndpoint?.url, endpointHealth = endpointHealth.takeIf { endpoint.url == rpcSettingsState.activeEndpoint?.url }, + endpointProbe = endpointProbe, onSelectEndpoint = { onSelectEndpoint(endpoint) }, onRemoveEndpoint = { onRemoveEndpoint(endpoint) }, ) @@ -345,6 +376,7 @@ private fun RpcEndpointRow( endpoint: RpcEndpoint, isActive: Boolean, endpointHealth: EndpointHealthState?, + endpointProbe: EndpointProbeResult?, onSelectEndpoint: () -> Unit, onRemoveEndpoint: () -> Unit, ) { @@ -357,6 +389,16 @@ private fun RpcEndpointRow( text = endpoint.url, fontFamily = FontFamily.Monospace, ) + LabelValue( + label = "Explorer URL", + value = endpoint.url, + monospace = true, + ) + LabelValue( + label = "RPC URL", + value = rpcUrlFromExplorerUrl(endpoint.url), + monospace = true, + ) if (endpointHealth != null) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { StatusChip( @@ -383,6 +425,19 @@ private fun RpcEndpointRow( ) } } + endpointProbe?.error?.let { failure -> + LabelValue( + label = "Last probe error", + value = failure.rawMessage ?: failure.message, + monospace = true, + ) + } + endpointProbe?.status?.let { status -> + LabelValue( + label = "Last probe finalized height", + value = (status.finalizedHeight ?: status.tipHeight).toString(), + ) + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), diff --git a/android/app/src/main/java/com/finalis/mobile/app/RpcSettingsRepository.kt b/android/app/src/main/java/com/finalis/mobile/app/RpcSettingsRepository.kt index 82fc18e..c22ac2f 100644 --- a/android/app/src/main/java/com/finalis/mobile/app/RpcSettingsRepository.kt +++ b/android/app/src/main/java/com/finalis/mobile/app/RpcSettingsRepository.kt @@ -18,6 +18,7 @@ import com.finalis.mobile.data.lightserver.LightserverDataException import com.finalis.mobile.data.lightserver.LightserverRepository import com.finalis.mobile.data.lightserver.LightserverRpcException import com.finalis.mobile.data.lightserver.MockLightserverRepository +import com.finalis.mobile.data.lightserver.UtxoDiagnosticsSnapshot import com.finalis.mobile.data.lightserver.normalizeExplorerUrl import com.finalis.mobile.data.lightserver.rpcUrlFromExplorerUrl @@ -155,10 +156,14 @@ class RuntimeLightserverRepository( private val endpointSettingsStore: RpcEndpointSettingsStore, private val repositoryFactory: (RpcEndpoint) -> LightserverRepository = ::explorerRepositoryFor, ) : LightserverRepository { + @Volatile + private var lastUtxoSnapshot: UtxoDiagnosticsSnapshot? = null + override suspend fun loadStatus(): NetworkIdentity { val endpoints = endpointSettingsStore.loadSettings().orderedEndpoints() require(endpoints.isNotEmpty()) { "No lightserver endpoints configured" } var lastFailureMessage: String? = null + var firstUnavailableMessage: String? = null for (endpoint in endpoints) { val repository = repositoryFactory(endpoint) val probe = probeRpcEndpoint(endpoint, repositoryFactory = { _ -> repository }) @@ -168,7 +173,9 @@ class RuntimeLightserverRepository( return probe.status } probe.error?.kind == EndpointErrorKind.UNAVAILABLE -> { - lastFailureMessage = probeDisplayMessage(probe) + val unavailableMessage = "${endpoint.url}: ${probe.error.rawMessage ?: probe.error.message}" + if (firstUnavailableMessage == null) firstUnavailableMessage = unavailableMessage + lastFailureMessage = unavailableMessage } probe.error != null -> { throw LightserverDataException(probeDisplayMessage(probe)) @@ -178,7 +185,7 @@ class RuntimeLightserverRepository( } } } - throw LightserverDataException(lastFailureMessage ?: "No reachable Finalis lightserver endpoint") + throw LightserverDataException(firstUnavailableMessage ?: lastFailureMessage ?: "No reachable Finalis lightserver endpoint") } override suspend fun validateAddress(address: String) = @@ -188,7 +195,11 @@ class RuntimeLightserverRepository( withValidatedEndpoint { repository, _, _ -> repository.loadBalance(address) } override suspend fun loadUtxos(address: String): List = - withValidatedEndpoint { repository, _, _ -> repository.loadUtxos(address) } + withValidatedEndpoint { repository, _, _ -> + repository.loadUtxos(address).also { + lastUtxoSnapshot = repository.lastUtxoDiagnostics() + } + } override suspend fun loadHistoryPage(address: String, cursor: String?, limit: Int, fromHeight: Long?): HistoryPageResult = withValidatedEndpoint { repository, _, _ -> repository.loadHistoryPage(address, cursor, limit, fromHeight) } @@ -207,12 +218,15 @@ class RuntimeLightserverRepository( require(endpoints.isNotEmpty()) { "No lightserver endpoints configured" } var lastResult: BroadcastResult? = null var lastFailureMessage: String? = null + var firstUnavailableMessage: String? = null for (endpoint in endpoints) { val repository = repositoryFactory(endpoint) val probe = probeRpcEndpoint(endpoint, repositoryFactory = { _ -> repository }) if (!probe.isValid) { if (probe.error?.kind == EndpointErrorKind.UNAVAILABLE) { - lastFailureMessage = probeDisplayMessage(probe) + val unavailableMessage = "${endpoint.url}: ${probe.error.rawMessage ?: probe.error.message}" + if (firstUnavailableMessage == null) firstUnavailableMessage = unavailableMessage + lastFailureMessage = unavailableMessage continue } throw LightserverDataException(probeDisplayMessage(probe)) @@ -232,27 +246,34 @@ class RuntimeLightserverRepository( } catch (error: Exception) { val failure = classifyEndpointFailure(error) if (failure.kind == EndpointErrorKind.UNAVAILABLE) { - lastFailureMessage = "${endpoint.url}: ${failure.message}" + val unavailableMessage = "${endpoint.url}: ${failure.rawMessage ?: failure.message}" + if (firstUnavailableMessage == null) firstUnavailableMessage = unavailableMessage + lastFailureMessage = unavailableMessage continue } throw error } } - return lastResult ?: throw LightserverDataException(lastFailureMessage ?: "No reachable Finalis lightserver endpoint") + return lastResult ?: throw LightserverDataException(firstUnavailableMessage ?: lastFailureMessage ?: "No reachable Finalis lightserver endpoint") } + override fun lastUtxoDiagnostics(): UtxoDiagnosticsSnapshot? = lastUtxoSnapshot + private suspend fun withValidatedEndpoint( block: suspend (LightserverRepository, NetworkIdentity, RpcEndpoint) -> T, ): T { val endpoints = endpointSettingsStore.loadSettings().orderedEndpoints() require(endpoints.isNotEmpty()) { "No lightserver endpoints configured" } var lastFailureMessage: String? = null + var firstUnavailableMessage: String? = null for (endpoint in endpoints) { val repository = repositoryFactory(endpoint) val probe = probeRpcEndpoint(endpoint, repositoryFactory = { _ -> repository }) if (!probe.isValid) { if (probe.error?.kind == EndpointErrorKind.UNAVAILABLE) { - lastFailureMessage = probeDisplayMessage(probe) + val unavailableMessage = "${endpoint.url}: ${probe.error.rawMessage ?: probe.error.message}" + if (firstUnavailableMessage == null) firstUnavailableMessage = unavailableMessage + lastFailureMessage = unavailableMessage continue } throw LightserverDataException(probeDisplayMessage(probe)) @@ -264,13 +285,15 @@ class RuntimeLightserverRepository( } catch (error: Exception) { val failure = classifyEndpointFailure(error) if (failure.kind == EndpointErrorKind.UNAVAILABLE) { - lastFailureMessage = "${endpoint.url}: ${failure.message}" + val unavailableMessage = "${endpoint.url}: ${failure.rawMessage ?: failure.message}" + if (firstUnavailableMessage == null) firstUnavailableMessage = unavailableMessage + lastFailureMessage = unavailableMessage continue } throw error } } - throw LightserverDataException(lastFailureMessage ?: "No reachable Finalis lightserver endpoint") + throw LightserverDataException(firstUnavailableMessage ?: lastFailureMessage ?: "No reachable Finalis lightserver endpoint") } } @@ -319,7 +342,7 @@ private fun explorerRepositoryFor(endpoint: RpcEndpoint): LightserverRepository private fun probeDisplayMessage(probe: EndpointProbeResult): String = when { probe.mismatch != null -> "${probe.endpoint.url}: ${probe.mismatch.message}" - probe.error != null -> "${probe.endpoint.url}: ${probe.error.message}" + probe.error != null -> "${probe.endpoint.url}: ${probe.error.rawMessage ?: probe.error.message}" else -> "${probe.endpoint.url}: endpoint probe failed" } @@ -332,38 +355,41 @@ fun classifyEndpointFailure(error: Throwable): EndpointFailure { is LightserverAddressException -> EndpointFailure( kind = if (error.wrongNetwork) EndpointErrorKind.ADDRESS_WRONG_NETWORK else EndpointErrorKind.ADDRESS_INVALID, message = message, + rawMessage = message, ) is LightserverBackendIncompatibleException -> EndpointFailure( kind = EndpointErrorKind.INCOMPATIBLE, message = "The endpoint does not satisfy the live Finalis lightserver contract. $message", + rawMessage = message, ) is LightserverRpcException -> EndpointFailure( kind = EndpointErrorKind.RPC_ERROR, message = "The lightserver returned an RPC error. $message", + rawMessage = message, ) is LightserverDataException -> { val lowerMessage = message.lowercase() when { "upstream unavailable" in lowerMessage || "unavailable" in lowerMessage || "timeout" in lowerMessage || "http 502" in lowerMessage || "http 503" in lowerMessage || "http 504" in lowerMessage -> - EndpointFailure(EndpointErrorKind.UNAVAILABLE, formatEndpointErrorMessage(message)) + EndpointFailure(EndpointErrorKind.UNAVAILABLE, formatEndpointErrorMessage(message), rawMessage = message) "http 400" in lowerMessage || "malformed address" in lowerMessage || "invalid address" in lowerMessage -> - EndpointFailure(EndpointErrorKind.ADDRESS_INVALID, formatEndpointErrorMessage(message)) + EndpointFailure(EndpointErrorKind.ADDRESS_INVALID, formatEndpointErrorMessage(message), rawMessage = message) "http 404" in lowerMessage || "not found in finalized state" in lowerMessage -> - EndpointFailure(EndpointErrorKind.RPC_ERROR, formatEndpointErrorMessage(message)) + EndpointFailure(EndpointErrorKind.RPC_ERROR, formatEndpointErrorMessage(message), rawMessage = message) "parsing failed" in lowerMessage || "malformed data" in lowerMessage || "missing result" in lowerMessage -> - EndpointFailure(EndpointErrorKind.MALFORMED_DATA, formatEndpointErrorMessage(message)) + EndpointFailure(EndpointErrorKind.MALFORMED_DATA, formatEndpointErrorMessage(message), rawMessage = message) "http " in lowerMessage -> - EndpointFailure(EndpointErrorKind.RPC_ERROR, formatEndpointErrorMessage(message)) + EndpointFailure(EndpointErrorKind.RPC_ERROR, formatEndpointErrorMessage(message), rawMessage = message) else -> - EndpointFailure(EndpointErrorKind.INCOMPATIBLE, formatEndpointErrorMessage(message)) + EndpointFailure(EndpointErrorKind.INCOMPATIBLE, formatEndpointErrorMessage(message), rawMessage = message) } } else -> when { generateSequence(error) { it.cause }.any { it is java.io.IOException } -> - EndpointFailure(EndpointErrorKind.UNAVAILABLE, formatEndpointErrorMessage(message)) + EndpointFailure(EndpointErrorKind.UNAVAILABLE, formatEndpointErrorMessage(message), rawMessage = message) else -> - EndpointFailure(EndpointErrorKind.UNKNOWN, message) + EndpointFailure(EndpointErrorKind.UNKNOWN, message, rawMessage = message) } } } diff --git a/android/app/src/main/java/com/finalis/mobile/app/SendSection.kt b/android/app/src/main/java/com/finalis/mobile/app/SendSection.kt index 58cf0c3..0e442ab 100644 --- a/android/app/src/main/java/com/finalis/mobile/app/SendSection.kt +++ b/android/app/src/main/java/com/finalis/mobile/app/SendSection.kt @@ -46,7 +46,7 @@ fun SendSection( if (finalizedBalanceUnits != null || spendableUnits != null) { finalizedBalanceUnits?.let { finalizedBalance -> LabelValue( - label = "Finalized balance", + label = "Spendable balance", value = formatFinalisAmountLabel(finalizedBalance), emphasize = true, ) diff --git a/android/app/src/main/java/com/finalis/mobile/app/WalletHomeSection.kt b/android/app/src/main/java/com/finalis/mobile/app/WalletHomeSection.kt index 43d83b9..7723277 100644 --- a/android/app/src/main/java/com/finalis/mobile/app/WalletHomeSection.kt +++ b/android/app/src/main/java/com/finalis/mobile/app/WalletHomeSection.kt @@ -191,7 +191,7 @@ fun WalletHomeSection( ) { WalletPanel( title = "Wallet overview", - subtitle = "Finalized balance and current sendable amount.", + subtitle = "Spendable balance and current sendable amount.", ) { Surface( shape = MaterialTheme.shapes.medium, @@ -205,7 +205,7 @@ fun WalletHomeSection( ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "FINALIZED BALANCE", + text = "SPENDABLE BALANCE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/android/app/src/main/java/com/finalis/mobile/app/WalletState.kt b/android/app/src/main/java/com/finalis/mobile/app/WalletState.kt index 96a4cc0..0ad8597 100644 --- a/android/app/src/main/java/com/finalis/mobile/app/WalletState.kt +++ b/android/app/src/main/java/com/finalis/mobile/app/WalletState.kt @@ -10,6 +10,8 @@ import com.finalis.mobile.core.model.TxDirection import com.finalis.mobile.core.model.TxStatus import com.finalis.mobile.core.model.WalletOutPoint import com.finalis.mobile.core.model.WalletUtxo +import com.finalis.mobile.data.lightserver.UtxoDiagnosticsSnapshot +import com.finalis.mobile.data.lightserver.rpcUrlFromExplorerUrl import com.finalis.mobile.core.wallet.NetworkMismatch as WalletNetworkMismatch sealed interface DashboardState { @@ -100,6 +102,8 @@ enum class SyncTrustState { data class EndpointHealthState( val activeEndpoint: RpcEndpoint? = null, + val resolvedExplorerUrl: String? = null, + val resolvedRpcUrl: String? = null, val reachability: EndpointReachabilityState = EndpointReachabilityState.UNKNOWN, val syncTrust: SyncTrustState = SyncTrustState.UNKNOWN, val status: NetworkIdentity? = null, @@ -112,7 +116,9 @@ data class EndpointHealthState( data class DiagnosticsState( val endpointHealth: EndpointHealthState = EndpointHealthState(), + val endpointProbes: List = emptyList(), val txDebugRecords: List = emptyList(), + val utxoDiagnostics: UtxoDiagnosticsSnapshot? = null, ) data class RpcEndpoint( @@ -139,6 +145,7 @@ data class EndpointProbeResult( data class EndpointFailure( val kind: EndpointErrorKind, val message: String, + val rawMessage: String? = null, ) data class FinalizedHistoryState( @@ -267,16 +274,20 @@ fun buildDiagnosticsState( status: NetworkIdentity?, mismatch: WalletNetworkMismatch? = null, error: EndpointFailure? = null, + endpointProbes: List = emptyList(), txDebugRecords: List = emptyList(), + utxoDiagnostics: UtxoDiagnosticsSnapshot? = null, ): DiagnosticsState { val endpointHealth = when { activeEndpoint == null -> EndpointHealthState() error != null -> EndpointHealthState( activeEndpoint = activeEndpoint, + resolvedExplorerUrl = activeEndpoint.url, + resolvedRpcUrl = rpcUrlFromExplorerUrl(activeEndpoint.url), reachability = if (error.kind == EndpointErrorKind.UNAVAILABLE) EndpointReachabilityState.UNREACHABLE else EndpointReachabilityState.REACHABLE, syncTrust = if (error.kind == EndpointErrorKind.UNAVAILABLE) SyncTrustState.UNREACHABLE else SyncTrustState.DEGRADED, errorKind = error.kind, - errorMessage = error.message, + errorMessage = error.rawMessage ?: error.message, summary = when (error.kind) { EndpointErrorKind.UNAVAILABLE -> "Endpoint unreachable" EndpointErrorKind.RPC_ERROR -> "Endpoint RPC error" @@ -291,6 +302,8 @@ fun buildDiagnosticsState( mismatch != null -> EndpointHealthState( activeEndpoint = activeEndpoint, + resolvedExplorerUrl = activeEndpoint.url, + resolvedRpcUrl = rpcUrlFromExplorerUrl(activeEndpoint.url), reachability = EndpointReachabilityState.REACHABLE, syncTrust = SyncTrustState.MISMATCHED, status = status, @@ -302,13 +315,17 @@ fun buildDiagnosticsState( status != null -> buildHealthyOrDegradedEndpointHealth(activeEndpoint, status) else -> EndpointHealthState( activeEndpoint = activeEndpoint, + resolvedExplorerUrl = activeEndpoint.url, + resolvedRpcUrl = rpcUrlFromExplorerUrl(activeEndpoint.url), summary = "Endpoint status unknown", detail = "No endpoint health information is currently available.", ) } return DiagnosticsState( endpointHealth = endpointHealth, + endpointProbes = endpointProbes, txDebugRecords = txDebugRecords, + utxoDiagnostics = utxoDiagnostics, ) } @@ -497,7 +514,7 @@ private fun buildHealthyOrDegradedEndpointHealth( ): EndpointHealthState { val degradedReasons = mutableListOf() val finalizedLag = status.finalizedLag - if (status.syncSnapshotPresent != true) { + if (status.syncSnapshotPresent == false) { degradedReasons += "Sync snapshot is unavailable." } if (status.bootstrapSyncIncomplete == true) { @@ -512,18 +529,18 @@ private fun buildHealthyOrDegradedEndpointHealth( if (status.nextHeightProposerAvailable == false) { degradedReasons += "Next-height proposer data is unavailable." } - if (status.observedNetworkHeightKnown != true) { + if (status.observedNetworkHeightKnown == false) { degradedReasons += "Observed network height is unknown." } - if (finalizedLag == null) { - degradedReasons += "Finalized lag is unavailable." - } else if (finalizedLag > 2L) { + if (finalizedLag != null && finalizedLag > 2L) { degradedReasons += "Finalized lag is $finalizedLag blocks." } return if (degradedReasons.isEmpty()) { EndpointHealthState( activeEndpoint = activeEndpoint, + resolvedExplorerUrl = activeEndpoint.url, + resolvedRpcUrl = rpcUrlFromExplorerUrl(activeEndpoint.url), reachability = EndpointReachabilityState.REACHABLE, syncTrust = SyncTrustState.HEALTHY, status = status, @@ -533,6 +550,8 @@ private fun buildHealthyOrDegradedEndpointHealth( } else { EndpointHealthState( activeEndpoint = activeEndpoint, + resolvedExplorerUrl = activeEndpoint.url, + resolvedRpcUrl = rpcUrlFromExplorerUrl(activeEndpoint.url), reachability = EndpointReachabilityState.REACHABLE, syncTrust = SyncTrustState.DEGRADED, status = status, diff --git a/android/app/src/main/java/com/finalis/mobile/app/WalletViewModel.kt b/android/app/src/main/java/com/finalis/mobile/app/WalletViewModel.kt index 9267303..3bd7b4c 100644 --- a/android/app/src/main/java/com/finalis/mobile/app/WalletViewModel.kt +++ b/android/app/src/main/java/com/finalis/mobile/app/WalletViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel import com.finalis.mobile.core.model.HistoryEntry import com.finalis.mobile.core.model.HistoryPageResult import com.finalis.mobile.core.model.ImportedWalletRecord +import com.finalis.mobile.core.model.BalanceSnapshot import com.finalis.mobile.core.model.SubmittedTransactionRecord import com.finalis.mobile.core.wallet.SubmittedTransactionManager import com.finalis.mobile.core.wallet.WalletNetworkGuard @@ -87,7 +88,7 @@ class WalletViewModel( ) } - fun syncRpcSettingsState(message: String? = rpcSettingsState.message) { + fun syncRpcSettingsState(message: String? = null) { rpcSettingsState = rpcSettingsRepository.loadUiState( inputValue = rpcSettingsState.inputValue, message = message, @@ -275,7 +276,10 @@ class WalletViewModel( val targetWallet = loadedWallet ?: return val generation = ++refreshGeneration var validatedStatus: com.finalis.mobile.core.model.NetworkIdentity? = null - readState = DashboardState.Loading + val previousState = readState + if (previousState !is DashboardState.Ready) { + readState = DashboardState.Loading + } beginRefresh() try { refreshMutex.withLock { @@ -291,6 +295,7 @@ class WalletViewModel( activeEndpoint = rpcSettingsState.activeEndpoint, status = status, mismatch = mismatch, + utxoDiagnostics = null, ) readState = if (mismatch != null) { DashboardState.NetworkMismatch( @@ -306,6 +311,13 @@ class WalletViewModel( val walletAddress = targetWallet.walletProfile.address.value val balance = withContext(Dispatchers.IO) { repository.loadBalance(walletAddress) } val utxos = withContext(Dispatchers.IO) { repository.loadUtxos(walletAddress) } + val effectiveBalance = BalanceSnapshot( + address = balance.address, + confirmedUnits = balance.confirmedUnits, + asset = balance.asset, + tipHeight = balance.tipHeight, + tipHash = balance.tipHash, + ) val historyPage = loadLatestHistoryBootstrapPage( repository = repository, address = walletAddress, @@ -336,7 +348,7 @@ class WalletViewModel( }.getOrNull() } val funds = summarizeWalletFunds( - finalizedBalance = balance.confirmedUnits, + finalizedBalance = effectiveBalance.confirmedUnits, finalizedUtxos = utxos, submitted = reconciliation.remainingSubmitted, ) @@ -359,10 +371,11 @@ class WalletViewModel( activeEndpoint = rpcSettingsState.activeEndpoint, status = status, txDebugRecords = txDebugRecords, + utxoDiagnostics = repository.lastUtxoDiagnostics(), ) DashboardState.Ready( status = status, - balance = balance, + balance = effectiveBalance, reservedUnits = funds.reservedUnits, spendableUnits = funds.spendableUnits, historyState = historyState, @@ -393,7 +406,13 @@ class WalletViewModel( ) } if (generation == refreshGeneration) { - readState = DashboardState.Error(friendlyError) + val previousReadyState = previousState as? DashboardState.Ready + if (previousReadyState != null && failure.kind == EndpointErrorKind.UNAVAILABLE) { + // Preserve last known-good wallet view on transient transport failures. + readState = previousReadyState + } else { + readState = DashboardState.Error(friendlyError) + } } } finally { syncRpcSettingsState() @@ -418,9 +437,16 @@ class WalletViewModel( activeEndpoint = null, status = null, error = null, + endpointProbes = emptyList(), ) return } + val probes = withContext(Dispatchers.IO) { + val endpoints = rpcSettingsRepository.loadSettings().orderedEndpoints() + endpoints.map { endpoint -> probeRpcEndpoint(endpoint) } + } + val activeProbe = probes.firstOrNull { it.endpoint.url == rpcSettingsState.activeEndpoint?.url } + val selectedProbe = activeProbe ?: probes.firstOrNull() try { val status = withContext(Dispatchers.IO) { repository.loadStatus() } syncRpcSettingsState() @@ -429,13 +455,15 @@ class WalletViewModel( activeEndpoint = rpcSettingsState.activeEndpoint, status = status, mismatch = mismatch, + endpointProbes = probes, ) } catch (error: Exception) { syncRpcSettingsState() diagnosticsState = buildDiagnosticsState( activeEndpoint = rpcSettingsState.activeEndpoint, status = null, - error = classifyEndpointFailure(error), + error = selectedProbe?.error ?: classifyEndpointFailure(error), + endpointProbes = probes, ) } } diff --git a/android/app/src/test/kotlin/com/finalis/mobile/app/RuntimeLightserverRepositoryTest.kt b/android/app/src/test/kotlin/com/finalis/mobile/app/RuntimeLightserverRepositoryTest.kt index 8bfb605..8b4e63a 100644 --- a/android/app/src/test/kotlin/com/finalis/mobile/app/RuntimeLightserverRepositoryTest.kt +++ b/android/app/src/test/kotlin/com/finalis/mobile/app/RuntimeLightserverRepositoryTest.kt @@ -21,29 +21,34 @@ import kotlin.test.fail class RuntimeLightserverRepositoryTest { @Test - fun `normalize endpoint url accepts explorer base urls`() { - assertEquals( - "https://lightserver.finalis.org", - normalizeRpcUrl("https://lightserver.finalis.org"), - ) - assertEquals( - "http://127.0.0.1:19444", - normalizeRpcUrl("http://127.0.0.1:19444/"), - ) - } + fun `normalize endpoint url accepts explorer base urls`() { + assertEquals( + "https://lightserver.finalis.org", + normalizeRpcUrl("https://lightserver.finalis.org"), + ) + assertEquals( + "http://127.0.0.1:18080", + normalizeRpcUrl("http://127.0.0.1:19444/"), + ) + } - @Test - fun `normalize endpoint url migrates legacy rpc urls to explorer format`() { - // Port 19444/rpc → port 18080 - assertEquals( - "http://127.0.0.1:18080", - normalizeRpcUrl("http://127.0.0.1:19444/rpc"), - ) - // Non-19444 rpc path → strip /rpc, keep port - assertEquals( - "https://lightserver.finalis.org", - normalizeRpcUrl("https://lightserver.finalis.org/rpc"), - ) + @Test + fun `normalize endpoint url migrates legacy rpc urls to explorer format`() { + // Port 19444/rpc → port 18080 + assertEquals( + "http://127.0.0.1:18080", + normalizeRpcUrl("http://127.0.0.1:19444/rpc"), + ) + // Non-19444 rpc path → strip /rpc, keep port + assertEquals( + "https://lightserver.finalis.org", + normalizeRpcUrl("https://lightserver.finalis.org/rpc"), + ) + // Full API paths should be normalized to base explorer endpoint + assertEquals( + "http://64.23.244.126:18080", + normalizeRpcUrl("http://64.23.244.126:18080/api/status"), + ) } @Test diff --git a/android/data/lightserver/src/main/java/com/finalis/mobile/data/lightserver/ExplorerRepository.kt b/android/data/lightserver/src/main/java/com/finalis/mobile/data/lightserver/ExplorerRepository.kt index a5bc084..415e86d 100644 --- a/android/data/lightserver/src/main/java/com/finalis/mobile/data/lightserver/ExplorerRepository.kt +++ b/android/data/lightserver/src/main/java/com/finalis/mobile/data/lightserver/ExplorerRepository.kt @@ -15,6 +15,7 @@ import com.finalis.mobile.core.wallet.FinalisMainnet import java.net.HttpURLConnection import java.net.URI import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException @@ -33,6 +34,19 @@ class ExplorerRepository( private val explorerBaseUrl: String, private val lightserverRpcUrl: String, ) : LightserverRepository { + companion object { + private const val STATUS_CACHE_TTL_MS = 5_000L + private const val ADDRESS_CACHE_TTL_MS = 5_000L + private const val STATUS_STALE_IF_ERROR_MAX_MS = 60_000L + private const val ADDRESS_STALE_IF_ERROR_MAX_MS = 60_000L + private val sharedStatusCache = ConcurrentHashMap>() + private val sharedAddressCache = ConcurrentHashMap>() + } + + private data class TimedValue( + val value: T, + val fetchedAtMillis: Long, + ) private val json = Json { ignoreUnknownKeys = true } private val explorerTransport = ExplorerHttpTransport(explorerBaseUrl) @@ -43,11 +57,17 @@ class ExplorerRepository( * probed the endpoint via [loadStatus] on the same instance before invoking [loadBalance]. */ @Volatile private var lastStatusDto: ExplorerStatusDto? = null + @Volatile + private var lastAddressPath: String? = null + @Volatile + private var lastAddressDto: ExplorerAddressDto? = null + @Volatile + private var lastUtxoSnapshot: UtxoDiagnosticsSnapshot? = null // ── Status ─────────────────────────────────────────────────────────────── override suspend fun loadStatus(): NetworkIdentity { - val status = explorerGet("/api/status") + val status = loadStatusCached() lastStatusDto = status if (!status.finalizedOnly) { throw LightserverBackendIncompatibleException( @@ -67,10 +87,40 @@ class ExplorerRepository( tipHash = status.finalizedTransitionHash, finalizedHeight = status.finalizedHeight, finalizedHash = status.finalizedTransitionHash, + healthyPeerCount = status.healthyPeerCount, + establishedPeerCount = status.establishedPeerCount, serverTruth = "finalized_only", proofsTipOnly = true, version = status.backendVersion, + walletApiVersion = status.walletApiVersion, syncMode = "finalized_only", + observedNetworkHeightKnown = status.sync?.observedNetworkHeightKnown, + observedNetworkFinalizedHeight = status.sync?.observedNetworkFinalizedHeight, + finalizedLag = status.sync?.finalizedLag, + bootstrapSyncIncomplete = status.sync?.bootstrapSyncIncomplete, + peerHeightDisagreement = status.sync?.peerHeightDisagreement, + checkpointDerivationMode = status.availability?.checkpointDerivationMode, + checkpointFallbackReason = status.availability?.checkpointFallbackReason, + fallbackSticky = status.availability?.fallbackSticky, + qualifiedDepth = status.availability?.adaptiveRegime?.qualifiedDepth, + adaptiveTargetCommitteeSize = status.availability?.adaptiveRegime?.adaptiveTargetCommitteeSize, + adaptiveMinEligible = status.availability?.adaptiveRegime?.adaptiveMinEligible, + adaptiveMinBond = status.availability?.adaptiveRegime?.adaptiveMinBond, + adaptiveSlack = status.availability?.adaptiveRegime?.slack, + targetExpandStreak = status.availability?.adaptiveRegime?.targetExpandStreak, + targetContractStreak = status.availability?.adaptiveRegime?.targetContractStreak, + adaptiveFallbackRateBps = status.availability?.adaptiveRegime?.fallbackRateBps, + adaptiveStickyFallbackRateBps = status.availability?.adaptiveRegime?.stickyFallbackRateBps, + adaptiveFallbackWindowEpochs = status.availability?.adaptiveRegime?.fallbackRateWindowEpochs, + adaptiveNearThresholdOperation = status.availability?.adaptiveRegime?.nearThresholdOperation, + adaptiveProlongedExpandBuildup = status.availability?.adaptiveRegime?.prolongedExpandBuildup, + adaptiveProlongedContractBuildup = status.availability?.adaptiveRegime?.prolongedContractBuildup, + adaptiveRepeatedStickyFallback = status.availability?.adaptiveRegime?.repeatedStickyFallback, + adaptiveDepthCollapseAfterBondIncrease = status.availability?.adaptiveRegime?.depthCollapseAfterBondIncrease, + adaptiveTelemetryWindowEpochs = status.availability?.adaptiveTelemetrySummary?.windowEpochs, + adaptiveTelemetrySampleCount = status.availability?.adaptiveTelemetrySummary?.sampleCount, + adaptiveTelemetryFallbackEpochs = status.availability?.adaptiveTelemetrySummary?.fallbackEpochs, + adaptiveTelemetryStickyFallbackEpochs = status.availability?.adaptiveTelemetrySummary?.stickyFallbackEpochs, ) } @@ -85,8 +135,9 @@ class ExplorerRepository( val validation = requireValidAddress(address) // Re-use the status already fetched by probeRpcEndpoint (same ExplorerRepository instance); // fall back to a fresh /api/status call only when the cache is absent. - val statusDto = lastStatusDto ?: explorerGet("/api/status") - val addressData = explorerGet("/api/address/${validation.normalizedAddress}") + val statusDto = lastStatusDto ?: loadStatusCached() + val endpointPath = "/api/address/${validation.normalizedAddress}" + val addressData = loadAddressCached(endpointPath) return BalanceSnapshot( address = WalletAddress(validation.normalizedAddress!!), confirmedUnits = addressData.finalizedBalance, @@ -100,30 +151,51 @@ class ExplorerRepository( override suspend fun loadUtxos(address: String): List { val validation = requireValidAddress(address) - val addressData = explorerGet("/api/address/${validation.normalizedAddress}") + val endpointPath = "/api/address/${validation.normalizedAddress}" + val addressData = loadAddressCached(endpointPath) if (!addressData.found) return emptyList() val utxos = addressData.utxos ?: return emptyList() val expectedScript = validation.scriptPubKeyHex!! - return utxos + val finalizedHeightHint = lastStatusDto?.finalizedHeight + val finalized = utxos .asSequence() - .filter { it.height > 0L } .map { utxo -> + val amountUnits = utxo.value ?: utxo.amount ?: throw LightserverDataException( + "Explorer UTXO missing both value and amount for ${utxo.txid}:${utxo.vout} at $endpointPath", + ) + // Some explorer deployments emit finalized UTXOs with height=0 while finalized_only=true. + // In finalized-only mode, promote zero-height rows to finalized using the latest known finalized height. + val effectiveHeight = when { + utxo.height > 0L -> utxo.height + addressData.finalizedOnly -> finalizedHeightHint ?: 1L + else -> 0L + } + if (effectiveHeight <= 0L) return@map null WalletUtxo( txid = utxo.txid.lowercase(), vout = utxo.vout, - valueUnits = utxo.value ?: utxo.amount ?: 0L, - height = utxo.height, + valueUnits = amountUnits, + height = effectiveHeight, scriptPubKeyHex = utxo.scriptPubKeyHex?.lowercase() ?: expectedScript, ) } + .filterNotNull() .sortedWith( compareByDescending { it.valueUnits } .thenBy { it.txid } .thenBy { it.vout }, ) .toList() + lastUtxoSnapshot = UtxoDiagnosticsSnapshot( + totalReturned = utxos.size, + finalizedKept = finalized.size, + filteredPending = (utxos.size - finalized.size).coerceAtLeast(0), + ) + return finalized } + override fun lastUtxoDiagnostics(): UtxoDiagnosticsSnapshot? = lastUtxoSnapshot + // ── History ────────────────────────────────────────────────────────────── override suspend fun loadHistoryPage( @@ -203,6 +275,66 @@ class ExplorerRepository( } } + private suspend fun loadAddressCached(path: String): ExplorerAddressDto { + if (lastAddressPath == path) { + lastAddressDto?.let { return it } + } + val cacheKey = "${explorerBaseUrl.trimEnd('/')}$path" + val now = System.currentTimeMillis() + sharedAddressCache[cacheKey]?.let { cached -> + if (now - cached.fetchedAtMillis <= ADDRESS_CACHE_TTL_MS) { + lastAddressPath = path + lastAddressDto = cached.value + return cached.value + } + } + val loaded = try { + explorerGet(path) + } catch (error: Exception) { + sharedAddressCache[cacheKey]?.let { stale -> + if (now - stale.fetchedAtMillis <= ADDRESS_STALE_IF_ERROR_MAX_MS) { + lastAddressPath = path + lastAddressDto = stale.value + return stale.value + } + } + throw error + } + sharedAddressCache[cacheKey] = TimedValue( + value = loaded, + fetchedAtMillis = now, + ) + lastAddressPath = path + lastAddressDto = loaded + return loaded + } + + private suspend fun loadStatusCached(): ExplorerStatusDto { + val path = "/api/status" + val cacheKey = "${explorerBaseUrl.trimEnd('/')}$path" + val now = System.currentTimeMillis() + sharedStatusCache[cacheKey]?.let { cached -> + if (now - cached.fetchedAtMillis <= STATUS_CACHE_TTL_MS) { + return cached.value + } + } + val loaded = try { + explorerGet(path) + } catch (error: Exception) { + sharedStatusCache[cacheKey]?.let { stale -> + if (now - stale.fetchedAtMillis <= STATUS_STALE_IF_ERROR_MAX_MS) { + return stale.value + } + } + throw error + } + sharedStatusCache[cacheKey] = TimedValue( + value = loaded, + fetchedAtMillis = now, + ) + return loaded + } + private fun requireValidAddress(address: String): AddressValidationResult { val result = validateAddressLocally(address) if (!result.valid) { @@ -219,8 +351,8 @@ class ExplorerRepository( class ExplorerHttpTransport( private val baseUrl: String, - private val connectTimeoutMs: Int = 5_000, - private val readTimeoutMs: Int = 5_000, + private val connectTimeoutMs: Int = 15_000, + private val readTimeoutMs: Int = 15_000, ) { private val errorJson = Json { ignoreUnknownKeys = true } @@ -253,7 +385,8 @@ class ExplorerHttpTransport( } responseBody } catch (exc: java.io.IOException) { - throw LightserverDataException("Explorer unavailable", exc) + val detail = exc.message?.takeIf { it.isNotBlank() } ?: exc.javaClass.simpleName + throw LightserverDataException("Explorer unavailable: $detail", exc) } finally { connection.disconnect() } @@ -279,14 +412,16 @@ fun normalizeExplorerUrl(rawUrl: String): String { require(scheme == "http" || scheme == "https") { "Endpoint URL must start with http:// or https://" } require(!parsed.host.isNullOrBlank()) { "Endpoint URL must include a host" } - // Auto-migrate legacy lightserver RPC URL → explorer URL - if (parsed.path.orEmpty().trimEnd('/').endsWith("/rpc")) { - val explorerPort = if (parsed.port == 19444) 18080 else parsed.port - return URI(scheme, parsed.userInfo, parsed.host, explorerPort, "", null, null).toString() - } + val normalizedPath = parsed.path.orEmpty().trimEnd('/') + val shouldStripPath = normalizedPath.isEmpty() || + normalizedPath == "/" || + normalizedPath.endsWith("/rpc") || + normalizedPath.startsWith("/api/") - val path = parsed.path.orEmpty().trimEnd('/') - return URI(scheme, parsed.userInfo, parsed.host, parsed.port, path, null, null).toString() + // Auto-migrate any lightserver RPC port input to explorer port. + val normalizedPort = if (parsed.port == 19444) 18080 else parsed.port + val basePath = if (shouldStripPath) "" else normalizedPath + return URI(scheme, parsed.userInfo, parsed.host, normalizedPort, basePath, null, null).toString() } /** @@ -386,12 +521,62 @@ private data class ExplorerStatusDto( @SerialName("finalized_height") val finalizedHeight: Long, @SerialName("finalized_transition_hash") val finalizedTransitionHash: String, @SerialName("backend_version") val backendVersion: String? = null, + @SerialName("wallet_api_version") val walletApiVersion: String? = null, @SerialName("network_id") val networkId: String? = null, @SerialName("genesis_hash") val genesisHash: String? = null, + @SerialName("healthy_peer_count") val healthyPeerCount: Int? = null, + @SerialName("established_peer_count") val establishedPeerCount: Int? = null, + val sync: ExplorerSyncDto? = null, + val availability: ExplorerAvailabilityDto? = null, @SerialName("ticket_pow") val ticketPow: ExplorerTicketPowDto? = null, @SerialName("finalized_only") val finalizedOnly: Boolean = true, ) +@Serializable +private data class ExplorerSyncDto( + @SerialName("observed_network_height_known") val observedNetworkHeightKnown: Boolean? = null, + @SerialName("observed_network_finalized_height") val observedNetworkFinalizedHeight: Long? = null, + @SerialName("finalized_lag") val finalizedLag: Long? = null, + @SerialName("bootstrap_sync_incomplete") val bootstrapSyncIncomplete: Boolean? = null, + @SerialName("peer_height_disagreement") val peerHeightDisagreement: Boolean? = null, +) + +@Serializable +private data class ExplorerAvailabilityDto( + @SerialName("checkpoint_derivation_mode") val checkpointDerivationMode: String? = null, + @SerialName("checkpoint_fallback_reason") val checkpointFallbackReason: String? = null, + @SerialName("fallback_sticky") val fallbackSticky: Boolean? = null, + @SerialName("adaptive_regime") val adaptiveRegime: ExplorerAdaptiveRegimeDto? = null, + @SerialName("adaptive_telemetry_summary") val adaptiveTelemetrySummary: ExplorerAdaptiveTelemetrySummaryDto? = null, +) + +@Serializable +private data class ExplorerAdaptiveRegimeDto( + @SerialName("qualified_depth") val qualifiedDepth: Long? = null, + @SerialName("adaptive_target_committee_size") val adaptiveTargetCommitteeSize: Long? = null, + @SerialName("adaptive_min_eligible") val adaptiveMinEligible: Long? = null, + @SerialName("adaptive_min_bond") val adaptiveMinBond: Long? = null, + val slack: Long? = null, + @SerialName("target_expand_streak") val targetExpandStreak: Long? = null, + @SerialName("target_contract_streak") val targetContractStreak: Long? = null, + @SerialName("fallback_rate_bps") val fallbackRateBps: Long? = null, + @SerialName("sticky_fallback_rate_bps") val stickyFallbackRateBps: Long? = null, + @SerialName("fallback_rate_window_epochs") val fallbackRateWindowEpochs: Long? = null, + @SerialName("near_threshold_operation") val nearThresholdOperation: Boolean? = null, + @SerialName("prolonged_expand_buildup") val prolongedExpandBuildup: Boolean? = null, + @SerialName("prolonged_contract_buildup") val prolongedContractBuildup: Boolean? = null, + @SerialName("repeated_sticky_fallback") val repeatedStickyFallback: Boolean? = null, + @SerialName("depth_collapse_after_bond_increase") val depthCollapseAfterBondIncrease: Boolean? = null, +) + +@Serializable +private data class ExplorerAdaptiveTelemetrySummaryDto( + @SerialName("window_epochs") val windowEpochs: Long? = null, + @SerialName("sample_count") val sampleCount: Long? = null, + @SerialName("fallback_epochs") val fallbackEpochs: Long? = null, + @SerialName("sticky_fallback_epochs") val stickyFallbackEpochs: Long? = null, +) + @Serializable private data class ExplorerTicketPowDto( val difficulty: Int? = null, diff --git a/android/data/lightserver/src/main/java/com/finalis/mobile/data/lightserver/LightserverRepository.kt b/android/data/lightserver/src/main/java/com/finalis/mobile/data/lightserver/LightserverRepository.kt index 1f041fd..abf3ecd 100644 --- a/android/data/lightserver/src/main/java/com/finalis/mobile/data/lightserver/LightserverRepository.kt +++ b/android/data/lightserver/src/main/java/com/finalis/mobile/data/lightserver/LightserverRepository.kt @@ -9,6 +9,12 @@ import com.finalis.mobile.core.model.NetworkIdentity import com.finalis.mobile.core.model.TxDetail import com.finalis.mobile.core.model.WalletUtxo +data class UtxoDiagnosticsSnapshot( + val totalReturned: Int, + val finalizedKept: Int, + val filteredPending: Int, +) + interface LightserverRepository { suspend fun loadStatus(): NetworkIdentity suspend fun validateAddress(address: String): AddressValidationResult @@ -19,4 +25,5 @@ interface LightserverRepository { suspend fun loadTxDetail(txid: String): TxDetail suspend fun findFinalizedTxDetail(txid: String): TxDetail? suspend fun broadcastTx(txHex: String): BroadcastResult + fun lastUtxoDiagnostics(): UtxoDiagnosticsSnapshot? = null } diff --git a/android/data/wallet/src/main/AndroidManifest.xml b/android/data/wallet/src/main/AndroidManifest.xml index 190974c..cc947c5 100644 --- a/android/data/wallet/src/main/AndroidManifest.xml +++ b/android/data/wallet/src/main/AndroidManifest.xml @@ -1 +1 @@ - + diff --git a/android/feature/history/src/main/AndroidManifest.xml b/android/feature/history/src/main/AndroidManifest.xml index dac1703..cc947c5 100644 --- a/android/feature/history/src/main/AndroidManifest.xml +++ b/android/feature/history/src/main/AndroidManifest.xml @@ -1 +1 @@ - + diff --git a/android/feature/home/src/main/AndroidManifest.xml b/android/feature/home/src/main/AndroidManifest.xml index a280254..cc947c5 100644 --- a/android/feature/home/src/main/AndroidManifest.xml +++ b/android/feature/home/src/main/AndroidManifest.xml @@ -1 +1 @@ - + diff --git a/android/feature/onboarding/src/main/AndroidManifest.xml b/android/feature/onboarding/src/main/AndroidManifest.xml index 8bfbff8..cc947c5 100644 --- a/android/feature/onboarding/src/main/AndroidManifest.xml +++ b/android/feature/onboarding/src/main/AndroidManifest.xml @@ -1 +1 @@ - + diff --git a/android/feature/receive/src/main/AndroidManifest.xml b/android/feature/receive/src/main/AndroidManifest.xml index e38dcc2..cc947c5 100644 --- a/android/feature/receive/src/main/AndroidManifest.xml +++ b/android/feature/receive/src/main/AndroidManifest.xml @@ -1 +1 @@ - + diff --git a/android/feature/send/src/main/AndroidManifest.xml b/android/feature/send/src/main/AndroidManifest.xml index cac98cd..cc947c5 100644 --- a/android/feature/send/src/main/AndroidManifest.xml +++ b/android/feature/send/src/main/AndroidManifest.xml @@ -1 +1 @@ - + diff --git a/android/feature/settings/src/main/AndroidManifest.xml b/android/feature/settings/src/main/AndroidManifest.xml index dddedee..cc947c5 100644 --- a/android/feature/settings/src/main/AndroidManifest.xml +++ b/android/feature/settings/src/main/AndroidManifest.xml @@ -1 +1 @@ - +