From ab5b34cbd81a2c7d9d40f702fe1e206ddecfcab6 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Wed, 23 Sep 2020 11:12:49 -0400 Subject: [PATCH] Implement the logic for changing servers. --- .../sdk/block/CompactBlockDownloader.kt | 90 ++++++++++++++++--- .../z/ecc/android/sdk/exception/Exceptions.kt | 29 +++++- .../sdk/service/LightWalletGrpcService.kt | 50 +++++++---- .../android/sdk/service/LightWalletService.kt | 31 +++---- 4 files changed, 153 insertions(+), 47 deletions(-) diff --git a/src/main/java/cash/z/ecc/android/sdk/block/CompactBlockDownloader.kt b/src/main/java/cash/z/ecc/android/sdk/block/CompactBlockDownloader.kt index bce8d588..bbc885da 100644 --- a/src/main/java/cash/z/ecc/android/sdk/block/CompactBlockDownloader.kt +++ b/src/main/java/cash/z/ecc/android/sdk/block/CompactBlockDownloader.kt @@ -1,8 +1,15 @@ package cash.z.ecc.android.sdk.block +import cash.z.ecc.android.sdk.exception.LightWalletException +import cash.z.ecc.android.sdk.ext.tryCatchWith +import cash.z.ecc.android.sdk.ext.tryWarn import cash.z.ecc.android.sdk.service.LightWalletService import cash.z.wallet.sdk.rpc.Service +import io.grpc.StatusRuntimeException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** @@ -11,13 +18,20 @@ import kotlinx.coroutines.withContext * these dependencies, the downloader remains agnostic to the particular implementation of how to retrieve and store * data; although, by default the SDK uses gRPC and SQL. * - * @property lightwalletService the service used for requesting compact blocks + * @property lightWalletService the service used for requesting compact blocks * @property compactBlockStore responsible for persisting the compact blocks that are received */ -open class CompactBlockDownloader( - val lightwalletService: LightWalletService, - val compactBlockStore: CompactBlockStore -) { +open class CompactBlockDownloader private constructor(val compactBlockStore: CompactBlockStore) { + + lateinit var lightWalletService: LightWalletService + private set + + constructor( + lightWalletService: LightWalletService, + compactBlockStore: CompactBlockStore + ) : this(compactBlockStore) { + this.lightWalletService = lightWalletService + } /** * Requests the given range of blocks from the lightwalletService and then persists them to the @@ -29,7 +43,7 @@ open class CompactBlockDownloader( * @return the number of blocks that were returned in the results from the lightwalletService. */ suspend fun downloadBlockRange(heightRange: IntRange): Int = withContext(IO) { - val result = lightwalletService.getBlockRange(heightRange) + val result = lightWalletService.getBlockRange(heightRange) compactBlockStore.write(result) result.size } @@ -50,7 +64,7 @@ open class CompactBlockDownloader( * @return the latest block height. */ suspend fun getLatestBlockHeight() = withContext(IO) { - lightwalletService.getLatestBlockHeight() + lightWalletService.getLatestBlockHeight() } /** @@ -63,14 +77,42 @@ open class CompactBlockDownloader( } suspend fun getServerInfo(): Service.LightdInfo = withContext(IO) { - lightwalletService.getServerInfo() + lightWalletService.getServerInfo() + } + + suspend fun changeService( + newService: LightWalletService, + errorHandler: (Throwable) -> Unit = { throw it } + ) = withContext(IO) { + try { + val existing = lightWalletService.getServerInfo() + val new = newService.getServerInfo() + val nonMatching = existing.essentialPropertyDiff(new) + + if (nonMatching.size > 0) { + errorHandler( + LightWalletException.ChangeServerException.ChainInfoNotMatching( + nonMatching.joinToString(), + existing, + new + ) + ) + } + + gracefullyShutdown(lightWalletService) + lightWalletService = newService + } catch (s: StatusRuntimeException) { + errorHandler(LightWalletException.ChangeServerException.StatusException(s.status)) + } catch (t: Throwable) { + errorHandler(t) + } } /** * Stop this downloader and cleanup any resources being used. */ fun stop() { - lightwalletService.shutdown() + lightWalletService.shutdown() compactBlockStore.close() } @@ -79,7 +121,35 @@ open class CompactBlockDownloader( * * @return the full transaction info. */ - fun fetchTransaction(txId: ByteArray) = lightwalletService.fetchTransaction(txId) + fun fetchTransaction(txId: ByteArray) = lightWalletService.fetchTransaction(txId) + + + // + // Convenience functions + // + + private suspend fun CoroutineScope.gracefullyShutdown(service: LightWalletService) = launch { + delay(2_000L) + tryWarn("Warning: error while shutting down service") { + service.shutdown() + } + } + + /** + * Return a list of critical properties that do not match. + */ + private fun Service.LightdInfo.essentialPropertyDiff(other: Service.LightdInfo) = + mutableListOf().also { + if (!consensusBranchId.equals(other.consensusBranchId, true)) { + it.add("consensusBranchId") + } + if (saplingActivationHeight != other.saplingActivationHeight) { + it.add("saplingActivationHeight") + } + if (!chainName.equals(other.chainName, true)) { + it.add("chainName") + } + } } diff --git a/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt b/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt index c944efac..0b18059a 100644 --- a/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt +++ b/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt @@ -1,5 +1,9 @@ package cash.z.ecc.android.sdk.exception +import cash.z.wallet.sdk.rpc.Service +import io.grpc.Status +import io.grpc.Status.Code.UNAVAILABLE + /** * Marker for all custom exceptions from the SDK. Making it an interface would result in more typing @@ -113,21 +117,40 @@ sealed class InitializerException(message: String, cause: Throwable? = null) : /** * Exceptions thrown while interacting with lightwalletd. */ -sealed class LightwalletException(message: String, cause: Throwable? = null) : SdkException(message, cause) { - object InsecureConnection : LightwalletException("Error: attempted to connect to lightwalletd" + +sealed class LightWalletException(message: String, cause: Throwable? = null) : SdkException(message, cause) { + object InsecureConnection : LightWalletException("Error: attempted to connect to lightwalletd" + " with an insecure connection! Plaintext connections are only allowed when the" + " resource value for 'R.bool.lightwalletd_allow_very_insecure_connections' is true" + " because this choice should be explicit.") class ConsensusBranchException(sdkBranch: String, lwdBranch: String) : - LightwalletException( + LightWalletException( "Error: the lightwalletd server is using a consensus branch" + " (branch: $lwdBranch) that does not match the transactions being created" + " (branch: $sdkBranch). This probably means the SDK and Server are on two" + " different chains, most likely because of a recent network upgrade (NU). Either" + " update the SDK to match lightwalletd or use a lightwalletd that matches the SDK." ) + + open class ChangeServerException(message: String, cause: Throwable? = null) : SdkException(message, cause) { + class ChainInfoNotMatching(val propertyNames: String, val expectedInfo: Service.LightdInfo, val actualInfo: Service.LightdInfo) : ChangeServerException( + "Server change error: the $propertyNames values did not match." + ) + class StatusException(val status: Status, cause: Throwable? = null) : SdkException(status.toMessage(), cause) { + companion object { + private fun Status.toMessage(): String { + return when(this.code) { + UNAVAILABLE -> { + "Error: the new server is unavailable. Verify that the host and port are correct. Failed with $this" + } + else -> "Changing servers failed with status $this" + } + } + } + } + } } + /** * Potentially user-facing exceptions thrown while encoding transactions. */ diff --git a/src/main/java/cash/z/ecc/android/sdk/service/LightWalletGrpcService.kt b/src/main/java/cash/z/ecc/android/sdk/service/LightWalletGrpcService.kt index f882cff6..1230f20d 100644 --- a/src/main/java/cash/z/ecc/android/sdk/service/LightWalletGrpcService.kt +++ b/src/main/java/cash/z/ecc/android/sdk/service/LightWalletGrpcService.kt @@ -2,7 +2,8 @@ package cash.z.ecc.android.sdk.service import android.content.Context import cash.z.ecc.android.sdk.R -import cash.z.ecc.android.sdk.exception.LightwalletException +import cash.z.ecc.android.sdk.annotation.OpenForTesting +import cash.z.ecc.android.sdk.exception.LightWalletException import cash.z.ecc.android.sdk.ext.ZcashSdk.DEFAULT_LIGHTWALLETD_PORT import cash.z.ecc.android.sdk.ext.twig import cash.z.wallet.sdk.rpc.CompactFormats @@ -18,18 +19,19 @@ import java.util.concurrent.TimeUnit * Implementation of LightwalletService using gRPC for requests to lightwalletd. * * @property channel the channel to use for communicating with the lightwalletd server. - * @property singleRequestTimeoutSec the timeout to use for non-streaming requests. When a new stub is - * created, it will use a deadline that is after the given duration from now. - * @property streamingRequestTimeoutSec the timeout to use for streaming requests. When a new stub is - * created for streaming requests, it will use a deadline that is after the given duration from now. + * @property singleRequestTimeoutSec the timeout to use for non-streaming requests. When a new stub + * is created, it will use a deadline that is after the given duration from now. + * @property streamingRequestTimeoutSec the timeout to use for streaming requests. When a new stub + * is created for streaming requests, it will use a deadline that is after the given duration from + * now. */ +@OpenForTesting class LightWalletGrpcService private constructor( var channel: ManagedChannel, private val singleRequestTimeoutSec: Long = 10L, private val streamingRequestTimeoutSec: Long = 90L ) : LightWalletService { - //TODO: find a better way to do this, maybe change the constructor to keep the properties lateinit var connectionInfo: ConnectionInfo /** @@ -46,7 +48,8 @@ class LightWalletGrpcService private constructor( appContext: Context, host: String, port: Int = DEFAULT_LIGHTWALLETD_PORT, - usePlaintext: Boolean = appContext.resources.getBoolean(R.bool.lightwalletd_allow_very_insecure_connections) + usePlaintext: Boolean = + appContext.resources.getBoolean(R.bool.lightwalletd_allow_very_insecure_connections) ) : this(createDefaultChannel(appContext, host, port, usePlaintext)) { connectionInfo = ConnectionInfo(appContext.applicationContext, host, port, usePlaintext) } @@ -57,17 +60,20 @@ class LightWalletGrpcService private constructor( if (heightRange.isEmpty()) return listOf() channel.resetConnectBackoff() - return channel.createStub(streamingRequestTimeoutSec).getBlockRange(heightRange.toBlockRange()).toList() + return channel.createStub(streamingRequestTimeoutSec) + .getBlockRange(heightRange.toBlockRange()).toList() } override fun getLatestBlockHeight(): Int { channel.resetConnectBackoff() - return channel.createStub(singleRequestTimeoutSec).getLatestBlock(Service.ChainSpec.newBuilder().build()).height.toInt() + return channel.createStub(singleRequestTimeoutSec) + .getLatestBlock(Service.ChainSpec.newBuilder().build()).height.toInt() } override fun getServerInfo(): Service.LightdInfo { channel.resetConnectBackoff() - return channel.createStub(singleRequestTimeoutSec).getLightdInfo(Service.Empty.newBuilder().build()) + return channel.createStub(singleRequestTimeoutSec) + .getLightdInfo(Service.Empty.newBuilder().build()) } override fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse { @@ -87,6 +93,7 @@ class LightWalletGrpcService private constructor( } override fun shutdown() { + twig("Shutting down channel") channel.shutdown() } @@ -94,7 +101,9 @@ class LightWalletGrpcService private constructor( if (txId.isEmpty()) return null channel.resetConnectBackoff() - return channel.createStub().getTransaction(Service.TxFilter.newBuilder().setHash(ByteString.copyFrom(txId)).build()) + return channel.createStub().getTransaction( + Service.TxFilter.newBuilder().setHash(ByteString.copyFrom(txId)).build() + ) } override fun getTAddressTransactions( @@ -112,8 +121,9 @@ class LightWalletGrpcService private constructor( } override fun reconnect() { - twig("closing existing channel and then reconnecting to" + - " ${connectionInfo.host}:${connectionInfo.port}?usePlaintext=${connectionInfo.usePlaintext}") + twig("closing existing channel and then reconnecting to ${connectionInfo.host}:" + + "${connectionInfo.port}?usePlaintext=${connectionInfo.usePlaintext}" + ) channel.shutdown() channel = createDefaultChannel( connectionInfo.appContext, @@ -128,12 +138,12 @@ class LightWalletGrpcService private constructor( // Utilities // - private fun Channel.createStub(timeoutSec: Long = 60L): CompactTxStreamerGrpc.CompactTxStreamerBlockingStub = - CompactTxStreamerGrpc - .newBlockingStub(this) - .withDeadlineAfter(timeoutSec, TimeUnit.SECONDS) + private fun Channel.createStub(timeoutSec: Long = 60L) = CompactTxStreamerGrpc + .newBlockingStub(this) + .withDeadlineAfter(timeoutSec, TimeUnit.SECONDS) - private inline fun Int.toBlockHeight(): Service.BlockID = Service.BlockID.newBuilder().setHeight(this.toLong()).build() + private inline fun Int.toBlockHeight(): Service.BlockID = + Service.BlockID.newBuilder().setHeight(this.toLong()).build() private inline fun IntRange.toBlockRange(): Service.BlockRange = Service.BlockRange.newBuilder() @@ -177,7 +187,9 @@ class LightWalletGrpcService private constructor( .context(appContext) .apply { if (usePlaintext) { - if (!appContext.resources.getBoolean(R.bool.lightwalletd_allow_very_insecure_connections)) throw LightwalletException.InsecureConnection + if (!appContext.resources.getBoolean( + R.bool.lightwalletd_allow_very_insecure_connections + )) throw LightWalletException.InsecureConnection usePlaintext() } else { useTransportSecurity() diff --git a/src/main/java/cash/z/ecc/android/sdk/service/LightWalletService.kt b/src/main/java/cash/z/ecc/android/sdk/service/LightWalletService.kt index 3102ddbf..3e577268 100644 --- a/src/main/java/cash/z/ecc/android/sdk/service/LightWalletService.kt +++ b/src/main/java/cash/z/ecc/android/sdk/service/LightWalletService.kt @@ -1,6 +1,5 @@ package cash.z.ecc.android.sdk.service -import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction import cash.z.wallet.sdk.rpc.CompactFormats import cash.z.wallet.sdk.rpc.Service @@ -9,6 +8,14 @@ import cash.z.wallet.sdk.rpc.Service * calls because async concerns are handled at a higher level. */ interface LightWalletService { + + /** + * Fetch the details of a known transaction. + * + * @return the full transaction info. + */ + fun fetchTransaction(txId: ByteArray): Service.RawTransaction? + /** * Return the given range of blocks. * @@ -46,13 +53,6 @@ interface LightWalletService { */ fun getServerInfo(): Service.LightdInfo - /** - * Submit a raw transaction. - * - * @return the response from the server. - */ - fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse - /** * Gets all the transactions for a given t-address over the given range. In practice, this is * effectively the same as an RPC call to a node that's running an insight server. The data is @@ -63,11 +63,10 @@ interface LightWalletService { fun getTAddressTransactions(tAddress: String, blockHeightRange: IntRange): List /** - * Fetch the details of a known transaction. - * - * @return the full transaction info. + * Reconnect to the same or a different server. This is useful when the connection is + * unrecoverable. That might be time to switch to a mirror or just reconnect. */ - fun fetchTransaction(txId: ByteArray): Service.RawTransaction? + fun reconnect() /** * Cleanup any connections when the service is shutting down and not going to be used again. @@ -75,8 +74,10 @@ interface LightWalletService { fun shutdown() /** - * Reconnect to the same or a different server. This is useful when the connection is - * unrecoverable. That might be time to switch to a mirror or just reconnect. + * Submit a raw transaction. + * + * @return the response from the server. */ - fun reconnect() + fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse + }