[#1393] Support server switching

- This ensures that the SDK supports apps switching between different lightwalletd servers
- Synchronizer.validateServerEndpoint API added
- Demo app updated to leverage this new feature
- Changelog update
- Closes #1393
This commit is contained in:
Honza Rychnovský 2024-03-02 18:55:22 +01:00 committed by GitHub
parent 36c803ab2e
commit 381bd42b89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 622 additions and 31 deletions

View File

@ -17,6 +17,13 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `WalletBalanceFixture` class with mock values that are supposed to be used only for testing purposes
- `Memo.countLength(memoString: String)` to count memo length in bytes
- `PersistableWallet.toSafeString` is a safe alternative for the regular [toString] function that prints only
non-sensitive parts
- `Synchronizer.validateServerEndpoint` this function checks whether the provided server endpoint is valid.
The validation is based on comparing:
* network type
* sapling activation height
* consensus branch id
## [2.0.6] - 2024-01-30

View File

@ -5,12 +5,17 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.viewModels
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
@ -21,22 +26,27 @@ import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.BALANCE
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.HOME
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.SEND
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.SERVER
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.TRANSACTIONS
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.WALLET_ADDRESS_DETAILS
import cash.z.ecc.android.sdk.demoapp.ui.screen.addresses.view.Addresses
import cash.z.ecc.android.sdk.demoapp.ui.screen.balance.view.Balance
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.view.Home
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.SecretState
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.WalletViewModel
import cash.z.ecc.android.sdk.demoapp.ui.screen.send.view.Send
import cash.z.ecc.android.sdk.demoapp.ui.screen.server.view.Server
import cash.z.ecc.android.sdk.demoapp.ui.screen.transactions.view.Transactions
import cash.z.ecc.android.sdk.demoapp.util.AndroidApiVersion
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.type.ServerValidation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
@Suppress("LongMethod", "ktlint:standard:function-naming")
@Suppress("LongMethod", "CyclomaticComplexMethod", "ktlint:standard:function-naming")
internal fun ComposeActivity.Navigation() {
val navController = rememberNavController()
@ -63,6 +73,7 @@ internal fun ComposeActivity.Navigation() {
}
},
goTransactions = { navController.navigateJustOnce(TRANSACTIONS) },
goServer = { navController.navigateJustOnce(SERVER) },
resetSdk = { walletViewModel.resetSdk() },
rewind = { walletViewModel.rewind() }
)
@ -136,6 +147,76 @@ internal fun ComposeActivity.Navigation() {
)
}
}
composable(SERVER) {
val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
Twig.info { "Current secrets state: $secretState" }
if (synchronizer == null || secretState !is SecretState.Ready) {
// Display loading indicator
} else {
val wallet = secretState.persistableWallet
Twig.info { "Current persisted wallet: ${wallet.toSafeString()}" }
val scope = rememberCoroutineScope()
var validationResult: ServerValidation by remember { mutableStateOf(ServerValidation.Valid) }
val onBack = {
if (validationResult !is ServerValidation.Running) {
navController.popBackStackJustOnce(SERVER)
}
}
BackHandler { onBack() }
Server(
buildInNetwork = ZcashNetwork.fromResources(application),
onBack = onBack,
onServerChange = { newEndpoint ->
scope.launch {
validationResult = ServerValidation.Running
validationResult = synchronizer.validateServerEndpoint(application, newEndpoint)
Twig.info { "Validation result: $validationResult" }
when (validationResult) {
ServerValidation.Valid -> {
walletViewModel.closeSynchronizer()
val newWallet =
wallet.copy(
endpoint = newEndpoint
)
Twig.info { "New wallet: ${newWallet.toSafeString()}" }
walletViewModel.persistExistingWallet(newWallet)
Toast.makeText(
applicationContext,
"Server saved",
Toast.LENGTH_SHORT
).show()
}
is ServerValidation.InValid -> {
Twig.error { "Failed to validate the new endpoint: $newEndpoint" }
}
else -> {
// Should not happen
Twig.info { "Server validation state: $validationResult" }
}
}
}
},
validationResult = validationResult,
wallet = wallet,
)
}
}
composable(TRANSACTIONS) {
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
if (null == synchronizer) {
@ -225,5 +306,7 @@ object NavigationTargets {
const val SEND = "send" // NON-NLS
const val SERVER = "server" // NON-NLS
const val TRANSACTIONS = "transactions" // NON-NLS
}

View File

@ -34,3 +34,8 @@ internal val LightWalletEndpoint.Companion.Testnet
DEFAULT_PORT,
isSecure = true
)
const val MIN_PORT_NUMBER = 1
const val MAX_PORT_NUMBER = 65535
internal fun LightWalletEndpoint.isValid() = host.isNotBlank() && port in MIN_PORT_NUMBER..MAX_PORT_NUMBER

View File

@ -44,6 +44,7 @@ private fun ComposablePreviewHome() {
goAddressDetails = {},
goTransactions = {},
goTestnetFaucet = {},
goServer = {},
resetSdk = {},
rewind = {},
)
@ -59,6 +60,7 @@ fun Home(
goSend: () -> Unit,
goAddressDetails: () -> Unit,
goTransactions: () -> Unit,
goServer: () -> Unit,
goTestnetFaucet: () -> Unit,
resetSdk: () -> Unit,
rewind: () -> Unit,
@ -76,6 +78,7 @@ fun Home(
walletSnapshot,
goBalance = goBalance,
goSend = goSend,
goServer = goServer,
goAddressDetails = goAddressDetails,
goTransactions = goTransactions
)
@ -155,8 +158,9 @@ private fun HomeMainContent(
walletSnapshot: WalletSnapshot,
goBalance: () -> Unit,
goSend: () -> Unit,
goServer: () -> Unit,
goAddressDetails: () -> Unit,
goTransactions: () -> Unit
goTransactions: () -> Unit,
) {
Column(
Modifier
@ -179,6 +183,10 @@ private fun HomeMainContent(
Text(text = stringResource(id = R.string.menu_transactions))
}
Button(goServer) {
Text(text = stringResource(id = R.string.menu_server))
}
Text(text = stringResource(id = R.string.home_status, walletSnapshot.status.toString()))
if (walletSnapshot.status != Synchronizer.Status.SYNCED) {
@Suppress("MagicNumber")

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletCoordinator
import cash.z.ecc.android.sdk.WalletInitMode
@ -267,6 +268,18 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
}
}
}
/**
* This safely and asynchronously stops the [Synchronizer].
*/
fun closeSynchronizer() {
val synchronizer = synchronizer.value
if (null != synchronizer) {
viewModelScope.launch {
(synchronizer as SdkSynchronizer).close()
}
}
}
}
/**

View File

@ -0,0 +1,304 @@
@file:Suppress("ktlint:standard:function-naming")
package cash.z.ecc.android.sdk.demoapp.ui.screen.server.view
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.demoapp.ext.Mainnet
import cash.z.ecc.android.sdk.demoapp.ext.Testnet
import cash.z.ecc.android.sdk.demoapp.ext.isValid
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.type.ServerValidation
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
@Preview(name = "Server")
@Composable
private fun ComposablePreview() {
MaterialTheme {
// TODO [#1090]: Demo: Add Compose Previews
// TODO [#1090]: https://github.com/zcash/zcash-android-wallet-sdk/issues/1090
// Server()
}
}
// North America | [na.lightwalletd.com](http://na.lightwalletd.com/) | 443
// South America | [sa.lightwalletd.com](http://sa.lightwalletd.com/) | 443
// Europe & Africa | [eu.lightwalletd.com](http://eu.lightwalletd.com/) | 443
// Asia & Oceania | [ai.lightwalletd.com](http://ai.lightwalletd.com/) | 443
// Plus current network defaults:
// Mainnet: mainnet.lightwalletd.com | 9067
// Testnet: lightwalletd.testnet.electriccoin.co | 9067
const val HOST_NA = "na.lightwalletd.com" // NON-NLS
const val HOST_SA = "sa.lightwalletd.com" // NON-NLS
const val HOST_EU = "eu.lightwalletd.com" // NON-NLS
const val HOST_AI = "ai.lightwalletd.com" // NON-NLS
const val PORT = 443
@Composable
fun Server(
buildInNetwork: ZcashNetwork,
onBack: () -> Unit,
onServerChange: (LightWalletEndpoint) -> Unit,
wallet: PersistableWallet,
validationResult: ServerValidation,
) {
Scaffold(
topBar = { ServerTopAppBar(onBack) },
) { paddingValues ->
ServerSwitch(
buildInNetwork = buildInNetwork,
wallet = wallet,
onServerChange = onServerChange,
validationResult = validationResult,
modifier =
Modifier.padding(
top = paddingValues.calculateTopPadding() + 16.dp,
bottom = paddingValues.calculateBottomPadding() + 16.dp,
start = 16.dp,
end = 16.dp
)
)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun ServerTopAppBar(onBack: () -> Unit) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.menu_server)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = null
)
}
}
)
}
@Composable
@Suppress("LongMethod")
fun ServerSwitch(
buildInNetwork: ZcashNetwork,
onServerChange: (LightWalletEndpoint) -> Unit,
wallet: PersistableWallet,
validationResult: ServerValidation,
modifier: Modifier = Modifier,
) {
Twig.info { "Currently selected wallet: ${wallet.toSafeString()}" }
val context = LocalContext.current
val options =
buildList {
if (buildInNetwork == ZcashNetwork.Mainnet) {
add(LightWalletEndpoint.Mainnet)
add(LightWalletEndpoint(HOST_NA, PORT, true))
add(LightWalletEndpoint(HOST_SA, PORT, true))
add(LightWalletEndpoint(HOST_EU, PORT, true))
add(LightWalletEndpoint(HOST_AI, PORT, true))
} else {
add(LightWalletEndpoint.Testnet)
}
if (contains(wallet.endpoint)) {
// The custom server is defined as secured by default
add(LightWalletEndpoint("", -1, true))
} else {
add(wallet.endpoint)
}
}.toMutableList()
var selectedOptionIndex: Int by rememberSaveable { mutableIntStateOf(options.indexOf(wallet.endpoint)) }
val initialCustomServerValue =
options.last().run {
if (options.last().isValid()) {
stringResource(R.string.server_textfield_value, options.last().host, options.last().port)
} else {
""
}
}
var customServerTextFieldValue: String by rememberSaveable { mutableStateOf(initialCustomServerValue) }
var customServerError: String? by rememberSaveable { mutableStateOf(null) }
LaunchedEffect(key1 = validationResult) {
when (validationResult) {
is ServerValidation.InValid -> {
customServerError = context.getString(R.string.server_textfield_error)
}
else -> {}
}
}
Column(
modifier =
modifier
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
options.forEachIndexed { index, endpoint ->
val isSelected = index == selectedOptionIndex
val isLast = index == options.lastIndex
if (!isLast) {
LabeledRadioButton(
endpoint = endpoint,
changeClick = { selectedOptionIndex = index },
name = stringResource(id = R.string.server_textfield_value, endpoint.host, endpoint.port),
selected = isSelected,
)
} else {
Column(
modifier = Modifier.animateContentSize()
) {
LabeledRadioButton(
endpoint = endpoint,
changeClick = { selectedOptionIndex = index },
name = stringResource(id = R.string.server_custom),
selected = isSelected,
)
if (isSelected) {
Spacer(modifier = Modifier.height(4.dp))
TextField(
value = customServerTextFieldValue,
onValueChange = {
customServerError = null
customServerTextFieldValue = it
},
placeholder = {
Text(text = stringResource(R.string.server_textfield_hint))
}
)
if (!customServerError.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = customServerError!!,
color = Color.Red,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
enabled = validationResult != ServerValidation.Running,
onClick = {
val selectedServer =
if (selectedOptionIndex == options.lastIndex) {
Twig.info { "Built custom server from: $customServerTextFieldValue" }
if (!validateCustomServerValue(customServerTextFieldValue)) {
customServerError = context.getString(R.string.server_textfield_error)
return@Button
}
customServerTextFieldValue.toEndpoint()
} else {
options[selectedOptionIndex]
}
Twig.info { "Selected server: $selectedServer" }
onServerChange(selectedServer)
},
modifier = Modifier.fillMaxWidth(fraction = 0.75f)
) {
Text(stringResource(id = R.string.server_save))
}
}
}
fun String.toEndpoint(): LightWalletEndpoint {
val parts = split(":")
return LightWalletEndpoint(parts[0], parts[1].toInt(), true)
}
// This regex validates server URLs with ports while ensuring:
// - Valid hostname format (excluding spaces and special characters)
// - Port numbers within the valid range (1-65535) and without leading zeros
// - Note that it doesn't cover other URL components like paths or query strings.
val regex = "^(([^:/?#\\s]+)://)?([^/?#\\s]+):([1-9][0-9]{3}|[1-5][0-9]{2}|[0-9]{1,2})$".toRegex()
fun validateCustomServerValue(customServer: String): Boolean = regex.matches(customServer)
@Composable
fun LabeledRadioButton(
name: String,
endpoint: LightWalletEndpoint,
selected: Boolean,
changeClick: (LightWalletEndpoint) -> Unit
) {
Row(
modifier =
Modifier
.wrapContentSize()
.clickable { changeClick(endpoint) },
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selected,
onClick = { changeClick(endpoint) }
)
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
modifier =
Modifier
.padding(all = 8.dp)
)
}
}

View File

@ -12,6 +12,7 @@
<string name="menu_home">Home</string>
<string name="menu_private_key">Get Private Key</string>
<string name="menu_address">Get Address</string>
<string name="menu_server">Server</string>
<string name="menu_balance">Get Balance</string>
<string name="menu_latest_height">Get Latest Height</string>
<string name="menu_block">Get Block</string>
@ -72,5 +73,11 @@
<string name="send_memo">Memo</string>
<string name="send_button">Send</string>
<string name="server_textfield_value"><xliff:g id="host" example="example.com">%1$s</xliff:g>:<xliff:g id="port" example="508">%2$d</xliff:g></string>
<string name="server_textfield_hint">&lt;host&gt;:&lt;port&gt;</string>
<string name="server_textfield_error">Invalid server</string>
<string name="server_custom">custom</string>
<string name="server_save">Save</string>
<string name="transactions_refresh">Refresh transactions</string>
</resources>

View File

@ -14,6 +14,8 @@ import co.electriccoin.lightwallet.client.model.SendResponseUnsafe
import co.electriccoin.lightwallet.client.model.ShieldedProtocolEnum
import co.electriccoin.lightwallet.client.model.SubtreeRootUnsafe
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Client for interacting with lightwalletd.
@ -114,5 +116,13 @@ interface LightWalletClient {
*/
fun LightWalletClient.Companion.new(
context: Context,
lightWalletEndpoint: LightWalletEndpoint
): LightWalletClient = LightWalletClientImpl.new(AndroidChannelFactory(context), lightWalletEndpoint)
lightWalletEndpoint: LightWalletEndpoint,
singleRequestTimeout: Duration = 10.seconds,
streamingRequestTimeout: Duration = 90.seconds
): LightWalletClient =
LightWalletClientImpl.new(
channelFactory = AndroidChannelFactory(context),
lightWalletEndpoint = lightWalletEndpoint,
singleRequestTimeout = singleRequestTimeout,
streamingRequestTimeout = streamingRequestTimeout
)

View File

@ -42,8 +42,8 @@ import kotlin.time.Duration.Companion.seconds
internal class LightWalletClientImpl private constructor(
private val channelFactory: ChannelFactory,
private val lightWalletEndpoint: LightWalletEndpoint,
private val singleRequestTimeout: Duration = 10.seconds,
private val streamingRequestTimeout: Duration = 90.seconds
private val singleRequestTimeout: Duration,
private val streamingRequestTimeout: Duration
) : LightWalletClient {
private var channel = channelFactory.newChannel(lightWalletEndpoint)
@ -260,9 +260,16 @@ internal class LightWalletClientImpl private constructor(
companion object {
fun new(
channelFactory: ChannelFactory,
lightWalletEndpoint: LightWalletEndpoint
lightWalletEndpoint: LightWalletEndpoint,
singleRequestTimeout: Duration = 10.seconds,
streamingRequestTimeout: Duration = 90.seconds
): LightWalletClientImpl {
return LightWalletClientImpl(channelFactory, lightWalletEndpoint)
return LightWalletClientImpl(
channelFactory = channelFactory,
lightWalletEndpoint = lightWalletEndpoint,
singleRequestTimeout = singleRequestTimeout,
streamingRequestTimeout = streamingRequestTimeout
)
}
}
}

View File

@ -1,6 +1,7 @@
package co.electriccoin.lightwallet.client.model
import cash.z.wallet.sdk.internal.rpc.Service
import java.util.Locale
/**
* A lightwalletd endpoint information, which has come from the Light Wallet server.
@ -10,14 +11,29 @@ import cash.z.wallet.sdk.internal.rpc.Service
data class LightWalletEndpointInfoUnsafe(
val chainName: String,
val consensusBranchId: String,
val blockHeightUnsafe: BlockHeightUnsafe
val blockHeightUnsafe: BlockHeightUnsafe,
val saplingActivationHeightUnsafe: BlockHeightUnsafe
) {
companion object {
internal fun new(lightdInfo: Service.LightdInfo) =
LightWalletEndpointInfoUnsafe(
lightdInfo.chainName,
lightdInfo.consensusBranchId,
BlockHeightUnsafe(lightdInfo.blockHeight)
BlockHeightUnsafe(lightdInfo.blockHeight),
BlockHeightUnsafe(lightdInfo.saplingActivationHeight),
)
}
// [chainName] is either "main" or "test"
fun matchingNetwork(network: String): Boolean {
fun String.toId() =
lowercase(Locale.ROOT).run {
when {
contains("main") -> "mainnet"
contains("test") -> "testnet"
else -> this
}
}
return chainName.toId() == network.toId()
}
}

View File

@ -56,6 +56,16 @@ data class PersistableWallet(
// For security, intentionally override the toString method to reduce risk of accidentally logging secrets
override fun toString() = "PersistableWallet"
/**
* This is a safe alternative for the regular [toString] function that prints only non-sensitive parts
*/
fun toSafeString() =
"PersistableWallet:" +
" network: $network," +
" endpoint: $endpoint," +
" birthday: $birthday," +
" wallet mode: $walletInitMode"
companion object {
internal const val VERSION_1 = 1
internal const val VERSION_2 = 2

View File

@ -11,6 +11,7 @@ import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Initia
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Stopped
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Synced
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Syncing
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException
import cash.z.ecc.android.sdk.exception.InitializeException
import cash.z.ecc.android.sdk.exception.TransactionEncoderException
import cash.z.ecc.android.sdk.exception.TransactionSubmitException
@ -51,6 +52,7 @@ import cash.z.ecc.android.sdk.type.AddressType.Shielded
import cash.z.ecc.android.sdk.type.AddressType.Transparent
import cash.z.ecc.android.sdk.type.AddressType.Unified
import cash.z.ecc.android.sdk.type.ConsensusMatchType
import cash.z.ecc.android.sdk.type.ServerValidation
import co.electriccoin.lightwallet.client.LightWalletClient
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.model.Response
@ -73,8 +75,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.CoroutineContext
import kotlin.time.Duration.Companion.seconds
/**
* A Synchronizer that attempts to remain operational, despite any number of errors that can occur.
@ -631,6 +635,8 @@ class SdkSynchronizer private constructor(
AddressType.Invalid(error.message ?: "Invalid")
}
// TODO [#1405]: Fix/Remove broken SdkSynchronizer.validateConsensusBranch function
// TODO [#1405]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/1405
override suspend fun validateConsensusBranch(): ConsensusMatchType {
val serverBranchId = tryNull { processor.downloader.getServerInfo()?.consensusBranchId }
@ -665,6 +671,89 @@ class SdkSynchronizer private constructor(
)
}
@Suppress("LongMethod", "ReturnCount")
override suspend fun validateServerEndpoint(
context: Context,
endpoint: LightWalletEndpoint
): ServerValidation {
// Create a dedicated light wallet client for the validation
// The single request timeout is changed from default to 5 seconds to speed up a possible custom server
// endpoint validation
val lightWalletClient =
LightWalletClient.new(
context = context,
lightWalletEndpoint = endpoint,
singleRequestTimeout = 5.seconds
)
val remoteInfo =
when (val response = lightWalletClient.getServerInfo()) {
is Response.Success -> response.result
is Response.Failure -> {
return ServerValidation.InValid(response.toThrowable())
}
}
// Check network type
if (!remoteInfo.matchingNetwork(network.networkName)) {
return ServerValidation.InValid(
CompactBlockProcessorException.MismatchedNetwork(
clientNetwork = network.networkName,
serverNetwork = remoteInfo.chainName
)
)
}
// Check sapling activation height
runCatching {
val remoteSaplingActivationHeight = remoteInfo.saplingActivationHeightUnsafe.toBlockHeight(network)
if (network.saplingActivationHeight != remoteSaplingActivationHeight) {
return ServerValidation.InValid(
CompactBlockProcessorException.MismatchedSaplingActivationHeight(
clientHeight = network.saplingActivationHeight.value,
serverHeight = remoteSaplingActivationHeight.value
)
)
}
}.getOrElse {
return ServerValidation.InValid(it)
}
val currentChainTip =
when (val response = lightWalletClient.getLatestBlockHeight()) {
is Response.Success -> {
runCatching { response.result.toBlockHeight(network) }.getOrElse {
return ServerValidation.InValid(it)
}
}
is Response.Failure -> {
return ServerValidation.InValid(response.toThrowable())
}
}
val sdkBranchId =
runCatching {
"%x".format(
Locale.ROOT,
backend.getBranchIdForHeight(currentChainTip)
)
}.getOrElse {
return ServerValidation.InValid(it)
}
// Check branch id
return if (remoteInfo.consensusBranchId.equals(sdkBranchId, true)) {
ServerValidation.Valid
} else {
ServerValidation.InValid(
CompactBlockProcessorException.MismatchedConsensusBranch(
sdkBranchId,
remoteInfo.consensusBranchId
)
)
}
}
@Throws(InitializeException.MissingDatabaseException::class)
override suspend fun getExistingDataDbFilePath(
context: Context,

View File

@ -24,6 +24,7 @@ import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.CheckpointTool
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.android.sdk.type.ConsensusMatchType
import cash.z.ecc.android.sdk.type.ServerValidation
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.model.Response
import kotlinx.coroutines.flow.Flow
@ -242,6 +243,22 @@ interface Synchronizer {
*/
suspend fun validateConsensusBranch(): ConsensusMatchType
/**
* This function checks whether the provided server endpoint is valid. The validation is based on comparing:
* - network types,
* - sapling activation heights
* - consensus branches
*
* @param endpoint LightWalletEndpoint data to be validated
* @param context Context
*
* @return an instance of [ServerValidation] that provides details about the validation result
*/
suspend fun validateServerEndpoint(
context: Context,
endpoint: LightWalletEndpoint
): ServerValidation
/**
* Validates the given address, returning information about why it is invalid. This is a
* convenience method that combines the behavior of [isValidShieldedAddr],

View File

@ -17,6 +17,7 @@ import cash.z.ecc.android.sdk.block.processor.model.VerifySuggestedScanRange
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.EnhanceTransactionError.EnhanceTxDecryptError
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.EnhanceTransactionError.EnhanceTxDownloadError
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.MismatchedConsensusBranch
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.MismatchedNetwork
import cash.z.ecc.android.sdk.exception.InitializeException
import cash.z.ecc.android.sdk.exception.LightWalletException
@ -53,7 +54,6 @@ import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.GetAddressUtxosReplyUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpointInfoUnsafe
import co.electriccoin.lightwallet.client.model.Response
import co.electriccoin.lightwallet.client.model.ShieldedProtocolEnum
import co.electriccoin.lightwallet.client.model.SubtreeRootUnsafe
@ -849,17 +849,17 @@ class CompactBlockProcessor internal constructor(
// Note: we could better signal network connection issue
CompactBlockProcessorException.BadBlockHeight(info.blockHeightUnsafe)
} else {
val clientBranch =
val clientBranchId =
"%x".format(
Locale.ROOT,
backend.getBranchIdForHeight(serverBlockHeight)
)
val network = backend.network.networkName
if (!clientBranch.equals(info.consensusBranchId, true)) {
MismatchedNetwork(
clientNetwork = network,
serverNetwork = info.chainName
if (!clientBranchId.equals(info.consensusBranchId, true)) {
MismatchedConsensusBranch(
clientBranchId = clientBranchId,
serverBranchId = info.consensusBranchId
)
} else if (!info.matchingNetwork(network)) {
MismatchedNetwork(
@ -2247,15 +2247,3 @@ class CompactBlockProcessor internal constructor(
}
}
}
private fun LightWalletEndpointInfoUnsafe.matchingNetwork(network: String): Boolean {
fun String.toId() =
lowercase(Locale.ROOT).run {
when {
contains("main") -> "mainnet"
contains("test") -> "testnet"
else -> this
}
}
return chainName.toId() == network.toId()
}

View File

@ -118,6 +118,21 @@ sealed class CompactBlockProcessorException(message: String, cause: Throwable? =
"updating the client or switching servers."
)
class MismatchedConsensusBranch(clientBranchId: String, serverBranchId: String) : CompactBlockProcessorException(
message =
"Incompatible server: this client expects a consensus branch $clientBranchId but it " +
"was $serverBranchId! Try updating the client or switching servers."
)
class MismatchedSaplingActivationHeight(
clientHeight: Long,
serverHeight: Long
) : CompactBlockProcessorException(
message =
"Incompatible server: this client expects a sapling activation height $clientHeight but it " +
"was $serverHeight! Try updating the client or switching servers."
)
class BadBlockHeight(serverBlockHeight: BlockHeightUnsafe) : CompactBlockProcessorException(
"The server returned a block height of $serverBlockHeight which is not valid."
)

View File

@ -22,12 +22,12 @@ enum class ConsensusBranchId(val displayName: String, val id: Long, val hexId: S
override fun toString(): String = displayName
companion object {
fun fromName(name: String): ConsensusBranchId? = values().firstOrNull { it.displayName.equals(name, true) }
fun fromName(name: String): ConsensusBranchId? = entries.firstOrNull { it.displayName.equals(name, true) }
fun fromId(id: Long): ConsensusBranchId? = values().firstOrNull { it.id == id }
fun fromId(id: Long): ConsensusBranchId? = entries.firstOrNull { it.id == id }
fun fromHex(hex: String): ConsensusBranchId? =
values().firstOrNull { branch ->
entries.firstOrNull { branch ->
hex.lowercase(Locale.US).replace("_", "").replaceFirst("0x", "").let { sanitized ->
branch.hexId.equals(sanitized, true)
}

View File

@ -0,0 +1,12 @@
package cash.z.ecc.android.sdk.type
/**
* Validation helper class, representing result an server endpoint validation
*/
sealed class ServerValidation {
data object Valid : ServerValidation()
data object Running : ServerValidation()
data class InValid(val reason: Throwable) : ServerValidation()
}