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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -76,6 +77,7 @@ fun DiagnosticsSection(
EndpointSettingsBlock(
rpcSettingsState = rpcSettingsState,
endpointHealth = endpointHealth,
endpointProbes = diagnosticsState.endpointProbes,
onRpcEndpointInputChange = onRpcEndpointInputChange,
onAddEndpoint = onAddEndpoint,
onSelectEndpoint = onSelectEndpoint,
Expand All @@ -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()) {
Expand Down Expand Up @@ -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)}")
Expand All @@ -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)) {
Expand All @@ -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) }
}
}
}
Expand Down Expand Up @@ -293,6 +321,7 @@ private fun abbreviateDebugTxid(txid: String): String =
private fun EndpointSettingsBlock(
rpcSettingsState: RpcSettingsState,
endpointHealth: EndpointHealthState,
endpointProbes: List<EndpointProbeResult>,
onRpcEndpointInputChange: (String) -> Unit,
onAddEndpoint: () -> Unit,
onSelectEndpoint: (RpcEndpoint) -> Unit,
Expand All @@ -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,
)

Expand All @@ -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) },
)
Expand All @@ -345,6 +376,7 @@ private fun RpcEndpointRow(
endpoint: RpcEndpoint,
isActive: Boolean,
endpointHealth: EndpointHealthState?,
endpointProbe: EndpointProbeResult?,
onSelectEndpoint: () -> Unit,
onRemoveEndpoint: () -> Unit,
) {
Expand All @@ -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(
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 })
Expand All @@ -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))
Expand All @@ -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) =
Expand All @@ -188,7 +195,11 @@ class RuntimeLightserverRepository(
withValidatedEndpoint { repository, _, _ -> repository.loadBalance(address) }

override suspend fun loadUtxos(address: String): List<WalletUtxo> =
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) }
Expand All @@ -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))
Expand All @@ -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 <T> 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))
Expand All @@ -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")
}
}

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

Expand All @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
Loading
Loading