[#615] Refactor lightwalletd client

* [#615] Refactor lightwalletd client

This moves the lightwalletd client code to a separate Gradle module, but does not yet do much clean up the public API (e.g. hiding generated GRPC objects).  That will be a followon change via #673

I’ve prototyped a safer version of the API that would be implemented for #673 for two specific calls: obtaining the current block height and obtaining the light wallet info.  These were the easiest endpoints to update and they provide a useful exploration for the future changes needed.

* Fix benchmarking for networking module

- Moved to fixture and build type check components to the new networking module, so it's accessible from all needed parts of the projects
- Changed fixture return type to fit to all needed usages

* Align with previous review comment

* Fix wrong merge

* Add basic sanity test

- To satisfy tests requirements on emulators.wtf service

* Remove grpc dependency from sdk-lib module

* Repackage cash.z.wallet.sdk.rpc to cash.z.wallet.sdk.internal.rpc

* Fix BuildConfig path

* Update demo-app paths to rpc

* Fix broken grpc services locations

- Our aim here is to change only the local services location (package name), the server location can't be changed due to backward compatibility.

* Enhance GRPC Response model

* Adopt enhanced GRPC response model

- Adopted in a few endpoints

* Enhance Connection failure type

* Add simple fixture test

* Refactor fetchTransaction() to work with Response

- Refactored this server call to adopt our new Response mechanism
- GrpsStatusResolver.resolveFailureFromStatus() now accepts Throwable input
- Added Response.Failure.toThrowable() to satisfy subsequent server calls results processing
- A few other minor refactoring changes

* Remove commented out log

* Unify return type of collection returning methods

- Switched fetchUtxos() to return Sequence instead of List
- Added a check on empty tAddress input

* fetchUtxos returns flow

- Switched fetchUtxos() to return Flow of Service.GetAddressUtxosReply
- Internally it calls getAddressUtxosStream() instead of getAddressUtxos() from GRPC layer

* Update unsafe suffix documentation

* Address several minor change requests

* Remove code parameter

- Removed from the locally triggered failures with default codes.

* Rename local variable

* Switch from empty response to exception

- Our server calls now rather throw IllegalArgumentException than return an empty response
- Removed commented out log message
- Updated javadocs

* Update proto files

- Plus related api changes adoption

* Unify new clients instances name

* AGP 7.4.0 changes

-  packagingOptions -> androidComponents in sdk-lib and lightwallet-client-lib modules

* SDK version bump

* Response resolver tests

* Release build upload timeout increase

* Release build upload timeout increase fix

* Revert timeout

- As Github has some infrastructure troubles and we need to wait

* Add migrations documentation

* Sort packaging filters

* Remove private field from public documentation

* Hide private variables

* Remove package from Android Manifest

* Throw exception instead of custom error

- This step unify our approach to validation on client side across all server calls

* Replace setAddresses index const with number

* Fix indentation in proto file

---------

Co-authored-by: Honza <rychnovsky.honza@gmail.com>
This commit is contained in:
Carter Jernigan 2023-02-01 05:14:55 -05:00 committed by GitHub
parent c2d0c2bc58
commit c0a2c11418
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
83 changed files with 2070 additions and 1016 deletions

1
.gitignore vendored
View File

@ -49,6 +49,7 @@ captures/
.idea/tasks.xml
.idea/vcs.xml
.idea/workspace.xml
.idea/protoeditor.xml
*.iml
# Keystore files

View File

@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name=":sdk-lib:connectedAndroidTest" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="zcash-android-sdk.sdk-lib" />
<module name="zcash-android-sdk.sdk-lib.androidTest" />
<option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" />
<option name="CLASS_NAME" value="" />
@ -8,13 +8,14 @@
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
<option name="EXTRA_OPTIONS" value="" />
<option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" />
<option name="RETENTION_ENABLED" value="No" />
<option name="RETENTION_MAX_SNAPSHOTS" value="2" />
<option name="RETENTION_COMPRESS_SNAPSHOTS" value="false" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="INSPECTION_WITHOUT_ACTIVITY_RESTART" value="false" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="2147483645" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="api-9130115880275692386-873230" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
@ -42,7 +43,7 @@
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Callstack Sample" />
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
</Profilers>

View File

@ -1,6 +1,16 @@
Change Log
==========
## 1.13.0-beta01
### Changed
- The SDK's internal networking has been refactored to a separate Gradle module `lightwallet-client-lib` (and
therefore a separate artifact) which is a transitive dependency of the Zcash Android SDK.
- The `z.cash.ecc.android.sdk.model.LightWalletEndpoint` class has been moved to `co.electriccoin.lightwallet.client.model.LightWalletEndpoint`
- The new networking module now provides a `BlockingLightWalletClient` for blocking calls and a
`CoroutineLightWalletClient` for asynchronous calls.
- Most unary calls respond with the new `Response` class and its subclasses. Streaming calls will be updated
with the Response class later.
## 1.12.0-beta01
### Changed
- `TransactionOverview`, `Transaction.Sent`, and `Transaction.Received` have `minedHeight` as a nullable field now. This fixes a potential crash when fetching transactions when a transaction is in the mempool

View File

@ -1,6 +1,16 @@
Troubleshooting Migrations
==========
Migration to Version 1.13
---------------------------------
Update usages of `z.cash.ecc.android.sdk.model.LightWalletEndpoint` to `co.electriccoin.lightwallet.client.model.LightWalletEndpoint`.
SDK clients should avoid using generated GRPC objects, as these are an internal implementation detail and are in process of being removed from the public API. Any clients using GRPC objects will find these have been repackaged from `cash.z.wallet.sdk.rpc` to `cash.z.wallet.sdk.internal.rpc` to signal they are not a public API.
Migration to Version 1.12
---------------------------------
`TransactionOverview`, `Transaction.Sent`, and `Transaction.Received` have been updated to reflect that `minedHeight` is nullable.
Migration to Version 1.11
---------------------------------
The way the SDK is initialized has changed. The `Initializer` object has been removed and `Synchronizer.new` now takes a longer parameter list which includes the parameters previously passed to `Initializer`.

View File

@ -23,6 +23,7 @@ android {
}
dependencies {
implementation(projects.lightwalletClientLib)
implementation(projects.sdkLib)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.core)

View File

@ -6,8 +6,8 @@ package cash.z.ecc.android.sdk.darkside // package cash.z.ecc.android.sdk.integr
// import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
// import cash.z.ecc.android.sdk.darkside.test.DarksideTestCoordinator
// import cash.z.ecc.android.sdk.util.SimpleMnemonics
// import cash.z.wallet.sdk.rpc.CompactFormats
// import cash.z.wallet.sdk.rpc.Service
// import cash.z.wallet.sdk.internal.rpc.CompactFormats
// import cash.z.wallet.sdk.internal.rpc.Service
// import io.grpc.*
// import kotlinx.coroutines.delay
// import kotlinx.coroutines.runBlocking

View File

@ -7,7 +7,7 @@ package cash.z.ecc.android.sdk.darkside.reorgs // package cash.z.ecc.android.sdk
// import cash.z.ecc.android.sdk.test.ScopedTest
// import cash.z.ecc.android.sdk.ext.import
// import cash.z.ecc.android.sdk.internal.twig
// import cash.z.ecc.android.sdk.darkside.test.DarksideApi
// import co.electriccoin.lightwallet.client.internal.DarksideApi
// import io.grpc.StatusRuntimeException
// import kotlinx.coroutines.delay
// import kotlinx.coroutines.flow.filter
@ -106,7 +106,7 @@ package cash.z.ecc.android.sdk.darkside.reorgs // package cash.z.ecc.android.sdk
// lightwalletd.getBlockRange(height..height).first()
//
// private val lightwalletd
// get() = (synchronizer as SdkSynchronizer).processor.downloader.lightwalletService
// get() = (synchronizer as SdkSynchronizer).processor.downloader.lightWalletClient
//
// companion object {
// private const val port = 9067

View File

@ -1,13 +1,17 @@
package cash.z.ecc.android.sdk.darkside.test
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.Darkside
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.internal.DarksideApi
import co.electriccoin.lightwallet.client.internal.new
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import io.grpc.StatusRuntimeException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
@ -54,13 +58,16 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
try {
twig("entering the darkside")
initiate()
synchronizer.getServerInfo().apply {
assertTrue(
"Error: not on the darkside",
vendor.contains("dark", true)
or chainName.contains("dark", true)
)
}
// In the future, we may want to have the SDK internally verify being on the darkside by matching the network type
// synchronizer.getServerInfo().apply {
// assertTrue(
// "Error: not on the darkside",
// vendor.contains("dark", true)
// or chainName.contains("dark", true)
// )
// }
twig("darkside initiation complete!")
} catch (error: StatusRuntimeException) {
Assert.fail(
@ -76,9 +83,8 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
*/
fun initiate() {
twig("*************** INITIALIZING TEST COORDINATOR (ONLY ONCE) ***********************")
val channel = synchronizer.channel
darkside = DarksideApi(channel)
darkside.reset()
darkside = DarksideApi.new(ApplicationProvider.getApplicationContext(), LightWalletEndpoint.Darkside)
darkside.reset(BlockHeightUnsafe(wallet.network.saplingActivationHeight.value))
}
// fun triggerSmallReorg() {
@ -243,30 +249,30 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
tipHeight: BlockHeight = startHeight + 100
): DarksideChainMaker = apply {
darkside
.reset(startHeight)
.reset(BlockHeightUnsafe(startHeight.value))
.stageBlocks(blocksUrl)
applyTipHeight(tipHeight)
}
fun stageTransaction(url: String, targetHeight: BlockHeight): DarksideChainMaker = apply {
darkside.stageTransactions(url, targetHeight)
darkside.stageTransactions(url, BlockHeightUnsafe(targetHeight.value))
}
fun stageTransactions(targetHeight: BlockHeight, vararg urls: String): DarksideChainMaker = apply {
urls.forEach {
darkside.stageTransactions(it, targetHeight)
darkside.stageTransactions(it, BlockHeightUnsafe(targetHeight.value))
}
}
fun stageEmptyBlocks(startHeight: BlockHeight, count: Int = 10): DarksideChainMaker = apply {
darkside.stageEmptyBlocks(startHeight, count)
darkside.stageEmptyBlocks(BlockHeightUnsafe(startHeight.value), count)
}
fun stageEmptyBlock() = stageEmptyBlocks(lastTipHeight!! + 1, 1)
fun applyTipHeight(tipHeight: BlockHeight): DarksideChainMaker = apply {
twig("applying tip height of $tipHeight")
darkside.applyBlocks(tipHeight)
darkside.applyBlocks(BlockHeightUnsafe(tipHeight.value))
lastTipHeight = tipHeight
}
@ -277,7 +283,7 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
*/
fun makeSimpleChain() {
darkside
.reset(DEFAULT_START_HEIGHT)
.reset(BlockHeightUnsafe(DEFAULT_START_HEIGHT.value))
.stageBlocks("https://raw.githubusercontent.com/zcash-hackworks/darksidewalletd-test-data/master/tx-incoming/blocks.txt")
applyTipHeight(DEFAULT_START_HEIGHT + 100)
}
@ -285,13 +291,13 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
fun advanceBy(numEmptyBlocks: Int) {
val nextBlock = lastTipHeight!! + 1
twig("adding $numEmptyBlocks empty blocks to the chain starting at $nextBlock")
darkside.stageEmptyBlocks(nextBlock, numEmptyBlocks)
darkside.stageEmptyBlocks(BlockHeightUnsafe(nextBlock.value), numEmptyBlocks)
applyTipHeight(nextBlock + numEmptyBlocks)
}
fun applyPendingTransactions(targetHeight: BlockHeight = lastTipHeight!! + 1) {
stageEmptyBlocks(lastTipHeight!! + 1, (targetHeight.value - lastTipHeight!!.value).toInt())
darkside.stageTransactions(darkside.getSentTransactions()?.iterator(), targetHeight)
darkside.stageTransactions(darkside.getSentTransactions()?.iterator(), BlockHeightUnsafe(targetHeight.value))
applyTipHeight(targetHeight)
}
}

View File

@ -6,17 +6,16 @@ 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.internal.Twig
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.Darkside
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.isPending
import cash.z.ecc.android.sdk.tool.DerivationTool
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.SupervisorJob
@ -72,10 +71,7 @@ class TestWallet(
endpoint,
seed,
startHeight
)
as
SdkSynchronizer
val service = (synchronizer.processor.downloader.lightWalletService as LightWalletGrpcService)
) as SdkSynchronizer
val available get() = synchronizer.saplingBalances.value?.available
val unifiedAddress =

View File

@ -7,16 +7,18 @@ import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
import cash.z.ecc.android.sdk.ext.toHex
import cash.z.ecc.android.sdk.internal.TroubleshootingTwig
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.Mainnet
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
import cash.z.ecc.android.sdk.model.isFailure
import cash.z.ecc.android.sdk.tool.DerivationTool
import co.electriccoin.lightwallet.client.BlockingLightWalletClient
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.new
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@ -63,7 +65,8 @@ class SampleCodeTest {
// ///////////////////////////////////////////////////
// Get Address
@Test fun getAddress() = runBlocking {
@Test
fun getAddress() = runBlocking {
val address = synchronizer.getUnifiedAddress()
assertFalse(address.isBlank())
log("Address: $address")
@ -71,25 +74,37 @@ class SampleCodeTest {
// ///////////////////////////////////////////////////
// Derive address from Extended Full Viewing Key
@Test fun getAddressFromViewingKey() {
@Test
fun getAddressFromViewingKey() {
}
// ///////////////////////////////////////////////////
// Query latest block height
@Test fun getLatestBlockHeightTest() {
val lightwalletService = LightWalletGrpcService.new(context, lightwalletdHost)
log("Latest Block: ${lightwalletService.getLatestBlockHeight()}")
@Test
fun getLatestBlockHeightTest() {
val lightwalletClient = BlockingLightWalletClient.new(context, lightwalletdHost)
log("Latest Block: ${lightwalletClient.getLatestBlockHeight()}")
}
// ///////////////////////////////////////////////////
// Download compact block range
@Test fun getBlockRange() {
val blockRange = BlockHeight.new(ZcashNetwork.Mainnet, 500_000)..BlockHeight.new(
ZcashNetwork.Mainnet,
500_009
@Test
fun getBlockRange() {
val blockRange = BlockHeightUnsafe(
BlockHeight.new(
ZcashNetwork.Mainnet,
500_000
).value
)..BlockHeightUnsafe(
(
BlockHeight.new(
ZcashNetwork.Mainnet,
500_009
).value
)
)
val lightwalletService = LightWalletGrpcService.new(context, lightwalletdHost)
val blocks = lightwalletService.getBlockRange(blockRange)
val lightwalletClient = BlockingLightWalletClient.new(context, lightwalletdHost)
val blocks = lightwalletClient.getBlockRange(blockRange)
assertEquals(blockRange.endInclusive.value - blockRange.start.value, blocks.count())
blocks.forEachIndexed { i, block ->
@ -99,12 +114,14 @@ class SampleCodeTest {
// ///////////////////////////////////////////////////
// Query account outgoing transactions
@Test fun queryOutgoingTransactions() {
@Test
fun queryOutgoingTransactions() {
}
// ///////////////////////////////////////////////////
// Query account incoming transactions
@Test fun queryIncomingTransactions() {
@Test
fun queryIncomingTransactions() {
}
// // ///////////////////////////////////////////////////
@ -126,7 +143,8 @@ class SampleCodeTest {
// ///////////////////////////////////////////////////
// Create a signed transaction (with memo) and broadcast
@Test fun submitTransaction() = runBlocking {
@Test
fun submitTransaction() = runBlocking {
val amount = 0.123.convertZecToZatoshi()
val address = "ztestsapling1tklsjr0wyw0d58f3p7wufvrj2cyfv6q6caumyueadq8qvqt8lda6v6tpx474rfru9y6u75u7qnw"
val memo = "Test Transaction"

View File

@ -23,11 +23,11 @@ import kotlinx.coroutines.launch
abstract class BaseDemoFragment<T : ViewBinding> : Fragment() {
/**
* Since the lightWalletService is not a component that apps typically use, directly, we provide
* Since the lightwalletClient is not a component that apps typically use, directly, we provide
* this from one place. Everything that can be done with the service can/should be done with the
* synchronizer because it wraps the service.
*/
val lightWalletService get() = mainActivity()?.lightWalletService
val lightWalletClient get() = mainActivity()?.lightwalletClient
// contains view information provided by the user
val sharedViewModel: SharedViewModel by activityViewModels()

View File

@ -21,12 +21,12 @@ import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import androidx.viewbinding.ViewBinding
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.service.LightWalletService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
import co.electriccoin.lightwallet.client.BlockingLightWalletClient
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.new
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.navigation.NavigationView
@ -45,7 +45,7 @@ class MainActivity :
* this object because it would utilize the synchronizer, instead, which exposes APIs that
* automatically sync with the server.
*/
var lightWalletService: LightWalletService? = null
var lightwalletClient: BlockingLightWalletClient? = null
private set
override fun onCreate(savedInstanceState: Bundle?) {
@ -81,7 +81,7 @@ class MainActivity :
override fun onDestroy() {
super.onDestroy()
lightWalletService?.shutdown()
lightwalletClient?.shutdown()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -127,11 +127,11 @@ class MainActivity :
//
private fun initService() {
if (lightWalletService != null) {
lightWalletService?.shutdown()
if (lightwalletClient != null) {
lightwalletClient?.shutdown()
}
val network = ZcashNetwork.fromResources(applicationContext)
lightWalletService = LightWalletGrpcService.new(
lightwalletClient = BlockingLightWalletClient.new(
applicationContext,
LightWalletEndpoint.defaultForNetwork(network)
)
@ -174,7 +174,6 @@ class MainActivity :
}
override fun onDrawerOpened(drawerView: View) {
twig("Drawer opened.")
hideKeyboard()
}
}

View File

@ -7,14 +7,14 @@ import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.ext.BenchmarkingExt
import cash.z.ecc.android.sdk.ext.onFirst
import cash.z.ecc.android.sdk.fixture.BlockRangeFixture
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
import co.electriccoin.lightwallet.client.ext.BenchmarkingExt
import co.electriccoin.lightwallet.client.fixture.BlockRangeFixture
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@ -78,7 +78,7 @@ class SharedViewModel(application: Application) : AndroidViewModel(application)
lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(network),
seed = seedBytes,
birthday = if (BenchmarkingExt.isBenchmarking()) {
BlockRangeFixture.new().start
BlockHeight.new(ZcashNetwork.Mainnet, BlockRangeFixture.new().start)
} else {
birthdayHeight.value
}

View File

@ -6,9 +6,9 @@ import cash.z.ecc.android.sdk.demoapp.model.PersistableWallet
import cash.z.ecc.android.sdk.demoapp.util.Twig
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.ext.onFirst
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers

View File

@ -15,10 +15,11 @@ import cash.z.ecc.android.sdk.demoapp.util.withCommas
import cash.z.ecc.android.sdk.ext.toHex
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import kotlin.math.min
/**
* Retrieves a compact block from the lightwalletd service and displays basic information about it.
* Retrieves a compact block from the lightwalletd server and displays basic information about it.
* This demonstrates the basic ability to connect to the server, request a compact block and parse
* the response.
*/
@ -26,7 +27,11 @@ class GetBlockFragment : BaseDemoFragment<FragmentGetBlockBinding>() {
private fun setBlockHeight(blockHeight: BlockHeight) {
val blocks =
lightWalletService?.getBlockRange(blockHeight..blockHeight)
lightWalletClient?.getBlockRange(
BlockHeightUnsafe(blockHeight.value)..BlockHeightUnsafe(
blockHeight.value
)
)
val block = blocks?.firstOrNull()
binding.textInfo.visibility = View.VISIBLE
binding.textInfo.text = HtmlCompat.fromHtml(

View File

@ -14,10 +14,11 @@ import cash.z.ecc.android.sdk.demoapp.util.toRelativeTime
import cash.z.ecc.android.sdk.demoapp.util.withCommas
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import kotlin.math.max
/**
* Retrieves a range of compact block from the lightwalletd service and displays basic information
* Retrieves a range of compact block from the lightwalletd server and displays basic information
* about them. This demonstrates the basic ability to connect to the server, request a range of
* compact block and parse the response. This could be augmented to display metadata about certain
* block ranges for instance, to find the block with the most shielded transactions in a range.
@ -27,7 +28,11 @@ class GetBlockRangeFragment : BaseDemoFragment<FragmentGetBlockRangeBinding>() {
private fun setBlockRange(blockRange: ClosedRange<BlockHeight>) {
val start = System.currentTimeMillis()
val blocks =
lightWalletService?.getBlockRange(blockRange)
lightWalletClient?.getBlockRange(
BlockHeightUnsafe(blockRange.start.value)..BlockHeightUnsafe(
blockRange.endInclusive.value
)
)
val fetchDelta = System.currentTimeMillis() - start
// Note: This is a demo so we won't worry about iterating efficiently over these blocks
@ -95,7 +100,12 @@ class GetBlockRangeFragment : BaseDemoFragment<FragmentGetBlockRangeBinding>() {
setText(R.string.loading)
binding.textInfo.setText(R.string.loading)
post {
setBlockRange(BlockHeight.new(network, start)..BlockHeight.new(network, end))
setBlockRange(
BlockHeight.new(network, start)..BlockHeight.new(
network,
end
)
)
isEnabled = true
setText(R.string.apply)
}

View File

@ -15,7 +15,7 @@ class GetLatestHeightFragment : BaseDemoFragment<FragmentGetLatestHeightBinding>
private fun displayLatestHeight() {
// note: this is a blocking call, a real app wouldn't do this on the main thread
// instead, a production app would leverage the synchronizer like in the other demos
binding.textInfo.text = lightWalletService?.getLatestBlockHeight().toString()
binding.textInfo.text = lightWalletClient?.getLatestBlockHeight().toString()
}
//

View File

@ -21,6 +21,7 @@ import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
@ -84,12 +85,12 @@ class ListUtxosFragment : BaseDemoFragment<FragmentListUtxosBinding>() {
?: getUxtoEndHeight(requireApplicationContext()).value
var allStart = now
twig("loading transactions in range $startToUse..$endToUse")
val txids = lightWalletService?.getTAddressTransactions(
val txids = lightWalletClient?.getTAddressTransactions(
addressToUse,
BlockHeight.new(network, startToUse)..BlockHeight.new(network, endToUse)
BlockHeightUnsafe(startToUse)..BlockHeightUnsafe(endToUse)
)
var delta = now - allStart
updateStatus("found ${txids?.size} transactions in ${delta}ms.", false)
updateStatus("found ${txids?.toList()?.size} transactions in ${delta}ms.", false)
txids?.map {
// Disabled during migration to newer SDK version; this appears to have been

View File

@ -2,8 +2,8 @@ package cash.z.ecc.android.sdk.demoapp.util
import android.os.Looper
import androidx.tracing.Trace
import cash.z.ecc.android.sdk.ext.BenchmarkingExt
import cash.z.ecc.android.sdk.internal.twig
import co.electriccoin.lightwallet.client.ext.BenchmarkingExt
interface BenchmarkTrace {
fun checkMainThread() {

View File

@ -4,7 +4,7 @@ import android.content.Context
import android.text.format.DateUtils
import androidx.fragment.app.Fragment
import cash.z.ecc.android.sdk.demoapp.MainActivity
import cash.z.wallet.sdk.rpc.CompactFormats
import cash.z.wallet.sdk.internal.rpc.CompactFormats
/**
* Lazy extensions to make demo life easier.

View File

@ -7,18 +7,47 @@ Thankfully, the only thing an app developer has to be concerned with is the foll
![SDK Diagram Developer Perspective](assets/sdk_dev_pov_final.png?raw=true "SDK Diagram Dev PoV")
# Components
# Modules
The SDK is broken down into several logical components, implemented as Gradle modules. At a high level, the modularization is:
| Component | Summary |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **LightWalletService** | Service used for requesting compact blocks |
| **CompactBlockStore** | Stores compact blocks that have been downloaded from the `LightWalletService` |
| **CompactBlockProcessor** | Validates and scans the compact blocks in the `CompactBlockStore` for transaction details |
| **OutboundTransactionManager** | Creates, Submits and manages transactions for spending funds |
| **DerivationTool** | Utilities for deriving keys and addresses |
| **RustBackend** | Wraps and simplifies the rust library and exposes its functionality to the Kotlin SDK |
* sdk-lib — Compiles all of the modules together for the SDK.
* lightwallet-client-lib — Provides a set of Kotlin APIs for interacting with lightwalletd over the network.
* darkside-test-lib — Contains integration tests for the SDK, running against a localhost lightwalletd instance running in darkside mode. This is not run as part of the SDK test suite, because it requires some manual setup to enable.
* demo-app — Contains a primitive demo application to exercise the SDK.
# Checkpoints
```mermaid
flowchart TB;
lightwalletClientLib[[lightwallet-client-lib]] --> sdkLib[[sdk-lib]];
sdkLib[[sdk-lib]] --> demoApp[[demo-app]];
sdkLib[[sdk-lib]] --> darksideTestLib[[darkside-test-lib]];
```
# Data model
Before diving into some of the module specifics, it is helpful to provide some context on the data model representations as data flows between the different modules of this repository. There are multiple data representations, including:
1. Network — The wire representation for calls to and from the Lightwalletd server. These are generated by `protoc` at compile time. These are not generally a public API.
2. Unsafe — The representation provided as the output from `lightwallet-client-lib`. These values are not necessarily validated, hence the smurf naming with the suffix `Unsafe`. These are not generally a public API for clients of the SDK.
3. SDK — Objects exposed as a public API for clients of the SDK.
# lightwallet-client-lib
This library is a work-in-progress.
This is generally not considered part of the public API, and much of the internals do not guarantee API stability. Internally, the implementation uses GRPC although over time that should be hidden from clients.
# sdk-lib
## Components
| Component | Summary |
|--------------------------------|-------------------------------------------------------------------------------------------|
| **LightWalletClient** | Component used for requesting compact blocks |
| **CompactBlockStore** | Stores compact blocks that have been downloaded from the `LightWalletClient` |
| **CompactBlockProcessor** | Validates and scans the compact blocks in the `CompactBlockStore` for transaction details |
| **OutboundTransactionManager** | Creates, Submits and manages transactions for spending funds |
| **DerivationTool** | Utilities for deriving keys and addresses |
| **RustBackend** | Wraps and simplifies the rust library and exposes its functionality to the Kotlin SDK |
## Checkpoints
To improve the speed of syncing with the Zcash network, the SDK contains a series of embedded checkpoints. These should be updated periodically, as new transactions are added to the network. Checkpoints are stored under the [sdk-lib's assets](../sdk-lib/src/main/assets/co.electriccoin.zcash/checkpoint) directory as JSON files. Checkpoints for both mainnet and testnet are bundled into the SDK.
To update the checkpoints, see [Checkmate](https://github.com/zcash-hackworks/checkmate).

View File

@ -22,7 +22,7 @@ ZCASH_ASCII_GPG_KEY=
# Configures whether release is an unstable snapshot, therefore published to the snapshot repository.
IS_SNAPSHOT=true
LIBRARY_VERSION=1.12.0-beta01
LIBRARY_VERSION=1.13.0-beta01
# Kotlin compiler warnings can be considered errors, failing the build.
ZCASH_IS_TREAT_WARNINGS_AS_ERRORS=true
@ -116,6 +116,7 @@ ANDROIDX_UI_AUTOMATOR_VERSION=2.3.0-alpha02
BIP39_VERSION=1.0.4
COROUTINES_OKHTTP=1.0
GOOGLE_MATERIAL_VERSION=1.7.0
GRPC_KOTLIN_VERSION=1.3.0
GRPC_VERSION=1.52.1
GSON_VERSION=2.9.0
GUAVA_VERSION=31.1-android

View File

@ -0,0 +1,240 @@
import com.google.protobuf.gradle.generateProtoTasks
import com.google.protobuf.gradle.id
import com.google.protobuf.gradle.plugins
import com.google.protobuf.gradle.proto
import com.google.protobuf.gradle.protobuf
import com.google.protobuf.gradle.protoc
import java.util.Locale
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("zcash-sdk.android-conventions")
id("org.jetbrains.dokka")
id("com.google.protobuf")
id("wtf.emulator.gradle")
id("zcash-sdk.emulator-wtf-conventions")
id("maven-publish")
id("signing")
}
// Publishing information
val publicationVariant = "release"
val myVersion = project.property("LIBRARY_VERSION").toString()
val myArtifactId = "client"
val isSnapshot = project.property("IS_SNAPSHOT").toString().toBoolean()
project.group = "co.electriccoin.lightwallet"
publishing {
publications {
register<MavenPublication>("release") {
artifactId = myArtifactId
version = if (isSnapshot) {
"$myVersion-SNAPSHOT"
} else {
myVersion
}
afterEvaluate {
from(components[publicationVariant])
}
pom {
name.set("Zcash Light Wallet Client")
description.set("Client API for connecting to the Light Wallet server.")
url.set("https://github.com/zcash/zcash-android-wallet-sdk/")
inceptionYear.set("2022")
scm {
url.set("https://github.com/zcash/zcash-android-wallet-sdk/")
connection.set("scm:git:git://github.com/zcash/zcash-android-wallet-sdk.git")
developerConnection.set("scm:git:ssh://git@github.com/zcash/zcash-android-wallet-sdk.git")
}
developers {
developer {
id.set("zcash")
name.set("Zcash")
url.set("https://github.com/zcash/")
}
}
licenses {
license {
name.set("The MIT License")
url.set("http://opensource.org/licenses/MIT")
distribution.set("repo")
}
}
}
}
}
repositories {
val mavenUrl = if (isSnapshot) {
project.property("ZCASH_MAVEN_PUBLISH_SNAPSHOT_URL").toString()
} else {
project.property("ZCASH_MAVEN_PUBLISH_RELEASE_URL").toString()
}
val mavenPublishUsername = project.property("ZCASH_MAVEN_PUBLISH_USERNAME").toString()
val mavenPublishPassword = project.property("ZCASH_MAVEN_PUBLISH_PASSWORD").toString()
mavenLocal {
name = "MavenLocal"
}
maven(mavenUrl) {
name = "MavenCentral"
credentials {
username = mavenPublishUsername
password = mavenPublishPassword
}
}
}
}
android {
namespace = "co.electriccoin.lightwallet.client"
useLibrary("android.test.runner")
defaultConfig {
consumerProguardFiles("proguard-consumer.txt")
}
buildTypes {
getByName("debug").apply {
// test builds exceed the dex limit because they pull in large test libraries
multiDexEnabled = true
isMinifyEnabled = false
}
getByName("release").apply {
multiDexEnabled = false
isMinifyEnabled = project.property("IS_MINIFY_SDK_ENABLED").toString().toBoolean()
proguardFiles.addAll(
listOf(
getDefaultProguardFile("proguard-android-optimize.txt"),
File("proguard-project.txt")
)
)
}
create("benchmark") {
// We provide the extra benchmark build type just for benchmarking purposes
initWith(buildTypes.getByName("release"))
matchingFallbacks += listOf("release")
}
}
sourceSets.getByName("main") {
proto { srcDir("src/main/proto") }
}
lint {
baseline = File("lint-baseline.xml")
}
publishing {
singleVariant(publicationVariant) {
withSourcesJar()
withJavadocJar()
}
}
}
androidComponents {
onVariants { variant ->
if (variant.name.toLowerCase(Locale.US).contains("release")) {
variant.packaging.resources.excludes.addAll(
listOf(
"META-INF/ASL2.0",
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE-notice.md",
"META-INF/LICENSE.md",
"META-INF/LICENSE.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/license.txt",
"META-INF/notice.txt"
)
)
}
}
}
tasks.dokkaHtml.configure {
dokkaSourceSets {
configureEach {
outputDirectory.set(file("build/docs/rtd"))
displayName.set("Lightwallet Client")
includes.from("packages.md")
}
}
}
protobuf {
protoc {
artifact = libs.protoc.compiler.get().asCoordinateString()
}
plugins {
id("java") {
artifact = libs.protoc.gen.java.get().asCoordinateString()
}
id("grpc") {
artifact = libs.protoc.gen.java.get().asCoordinateString()
}
id("grpckt") {
artifact = libs.protoc.gen.kotlin.get().asCoordinateString() + ":jdk8@jar"
}
}
generateProtoTasks {
all().forEach {
it.plugins {
id("java") {
option("lite")
}
id("grpc") {
option("lite")
}
id("grpckt") {
option("lite")
}
}
it.builtins {
id("kotlin") {
option("lite")
}
}
}
}
}
dependencies {
implementation(libs.androidx.annotation)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
// TODO [#673]: Make `implementation` https://github.com/zcash/zcash-android-wallet-sdk/issues/673
api(libs.bundles.grpc)
// Tests
testImplementation(libs.kotlin.reflect)
testImplementation(libs.kotlin.test)
testImplementation(libs.grpc.testing)
androidTestImplementation(libs.androidx.multidex)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.coroutines.okhttp)
androidTestImplementation(libs.kotlin.test)
}
tasks {
getByName("preBuild").dependsOn(create("bugfixTask") {
doFirst {
mkdir("build/extracted-include-protos/main")
}
})
}
fun MinimalExternalModuleDependency.asCoordinateString() =
"${module.group}:${module.name}:${versionConstraint.displayName}"

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="5" by="lint 4.2.1" client="gradle" variant="all" version="4.2.1">
<issue
id="DefaultLocale"
message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
errorLine1=" (t.message?.toLowerCase()?.contains(unlessContains.toLowerCase()) == true)"
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/java/cash/z/ecc/android/sdk/ext/Ext.kt"
line="29"
column="29"/>
</issue>
<issue
id="DefaultLocale"
message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
errorLine1=" (t.message?.toLowerCase()?.contains(unlessContains.toLowerCase()) == true)"
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/java/cash/z/ecc/android/sdk/ext/Ext.kt"
line="29"
column="68"/>
</issue>
<issue
id="DefaultLocale"
message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
errorLine1=" (t.message?.toLowerCase()?.contains(ifContains.toLowerCase()) == false)"
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/java/cash/z/ecc/android/sdk/ext/Ext.kt"
line="33"
column="33"/>
</issue>
<issue
id="DefaultLocale"
message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
errorLine1=" (t.message?.toLowerCase()?.contains(ifContains.toLowerCase()) == false)"
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/java/cash/z/ecc/android/sdk/ext/Ext.kt"
line="33"
column="68"/>
</issue>
</issues>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
</lint>

View File

@ -0,0 +1,7 @@
# Module lightwallet-client-lib
Light Wallet Client SDK.
# Package co.electriccoin.lightwallet.client.model
Integration tests designed to be executed with darksidewalletd.

View File

@ -0,0 +1,22 @@
-keepclasseswithmembernames,includedescriptorclasses class * {
native <methods>;
}
# https://github.com/grpc/grpc-java/blob/master/android/proguard-rules.txt
-keepclassmembers class io.grpc.okhttp.OkHttpChannelBuilder {
io.grpc.okhttp.OkHttpChannelBuilder forTarget(java.lang.String);
io.grpc.okhttp.OkHttpChannelBuilder scheduledExecutorService(java.util.concurrent.ScheduledExecutorService);
io.grpc.okhttp.OkHttpChannelBuilder sslSocketFactory(javax.net.ssl.SSLSocketFactory);
io.grpc.okhttp.OkHttpChannelBuilder transportExecutor(java.util.concurrent.Executor);
}
# Prevent OKHttp from causing warnings for consumers of the SDK
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE

View File

@ -0,0 +1,18 @@
# This improves obfuscation and moves non-public classes to their own namespace.
-repackageclasses 'co.electriccoin.lightwallet.client.internal'
# This makes it easier to autocomplete methods in an IDE using this obfuscated library.
-keepparameternames
# The ProGuard manual recommends keeping these attributes for libraries.
-keepattributes EnclosingMethod,InnerClasses,Signature,Exceptions,*Annotation*
# Ensure that stacktraces are reversible.
-renamesourcefileattribute SourceFile
-keepattributes SourceFile,LineNumberTable
# Keep the public interface of the library.
# Some of these will need to be tuned in the future, as they shouldn't ALL be considered part of the
# public API. Much of this will be improved by further repackaging of the classes.
-keep public class co.electriccoin.lightwallet.client.model.* { public protected *; }
-keep public class co.electriccoin.lightwallet.client.* { public protected *; }

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<!-- For code coverage -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application android:name="androidx.multidex.MultiDexApplication" />
</manifest>

View File

@ -0,0 +1,22 @@
package co.electriccoin.lightwallet.client.ext
import androidx.test.filters.SmallTest
import co.electriccoin.lightwallet.client.BuildConfig
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BenchmarkingExtTest {
@Test
@SmallTest
fun check_build_config() {
val benchmarkType = "benchmark" // $NON-NLS
if (BuildConfig.BUILD_TYPE.contains(benchmarkType)) {
assertTrue(BenchmarkingExt.isBenchmarking())
} else {
assertFalse(BenchmarkingExt.isBenchmarking())
}
}
}

View File

@ -0,0 +1,17 @@
package co.electriccoin.lightwallet.client.fixture
import androidx.test.filters.SmallTest
import kotlin.test.Test
import kotlin.test.assertEquals
class BlockRangeFixtureTest {
@Test
@SmallTest
fun compare_default_values() {
BlockRangeFixture.new().also {
assertEquals(BlockRangeFixture.BLOCK_HEIGHT_LOWER_BOUND, it.start)
assertEquals(BlockRangeFixture.BLOCK_HEIGHT_UPPER_BOUND, it.endInclusive)
}
}
}

View File

@ -0,0 +1,13 @@
package co.electriccoin.lightwallet.client.fixture
import io.grpc.Status
import io.grpc.StatusRuntimeException
object StatusExceptionFixture {
fun new(
status: Status
): StatusRuntimeException {
return StatusRuntimeException(status)
}
}

View File

@ -0,0 +1,19 @@
package co.electriccoin.lightwallet.client.internal
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.test.getAppContext
import kotlin.test.Ignore
import kotlin.test.Test
class ChannelFactoryTest {
private val channelFactory = AndroidChannelFactory(getAppContext())
@Test
@SmallTest
@Ignore("Finish the test once we can reach the needed model classes.")
fun new_channel_sanity_test() {
// TODO [#897]: Prepare new model classes module
// TODO [#897]: https://github.com/zcash/zcash-android-wallet-sdk/issues/897
}
}

View File

@ -0,0 +1,59 @@
package co.electriccoin.lightwallet.client.internal
import androidx.test.filters.SmallTest
import co.electriccoin.lightwallet.client.fixture.StatusExceptionFixture
import co.electriccoin.lightwallet.client.model.Response
import io.grpc.Status
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class GrpcStatusResolverTest {
@Test
@SmallTest
fun resolve_explicitly_caught_server_error_test() {
GrpcStatusResolver.resolveFailureFromStatus<Unit>(
StatusExceptionFixture.new(Status.NOT_FOUND)
).also { resolvedResponse ->
assertTrue(resolvedResponse is Response.Failure.Server.NotFound<Unit>)
assertEquals(resolvedResponse.code, Status.NOT_FOUND.code.value())
assertNull(resolvedResponse.description)
}
}
@Test
@SmallTest
fun resolve_explicitly_caught_error_client_test() {
GrpcStatusResolver.resolveFailureFromStatus<Unit>(
StatusExceptionFixture.new(Status.CANCELLED)
).also { resolvedResponse ->
assertTrue(resolvedResponse is Response.Failure.Client.Canceled<Unit>)
assertEquals(resolvedResponse.code, Status.CANCELLED.code.value())
assertNull(resolvedResponse.description)
}
}
@Test
@SmallTest
fun resolve_other_test() {
GrpcStatusResolver.resolveFailureFromStatus<Unit>(
StatusExceptionFixture.new(Status.INTERNAL)
).also { resolvedResponse ->
assertTrue(resolvedResponse is Response.Failure.Server.Other<Unit>)
assertEquals(resolvedResponse.code, Status.INTERNAL.code.value())
assertNull(resolvedResponse.description)
}
}
@Test
@SmallTest
fun resolve_unexpected_error_test() {
GrpcStatusResolver.resolveFailureFromStatus<Unit>(
IllegalArgumentException("This should not come into the resolver.")
).also { resolvedResponse ->
assertTrue(resolvedResponse is Response.Failure.Server.Unknown<Unit>)
}
}
}

View File

@ -0,0 +1,12 @@
package cash.z.ecc.android.sdk.test
import android.content.Context
import androidx.annotation.StringRes
import androidx.test.core.app.ApplicationProvider
fun getAppContext(): Context = ApplicationProvider.getApplicationContext()
fun getStringResource(@StringRes resId: Int) = getAppContext().getString(resId)
fun getStringResourceWithArgs(@StringRes resId: Int, vararg formatArgs: String) =
getAppContext().getString(resId, *formatArgs)

View File

@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View File

@ -0,0 +1,103 @@
package co.electriccoin.lightwallet.client
import android.content.Context
import cash.z.wallet.sdk.internal.rpc.CompactFormats
import cash.z.wallet.sdk.internal.rpc.Service
import co.electriccoin.lightwallet.client.internal.AndroidChannelFactory
import co.electriccoin.lightwallet.client.internal.BlockingLightWalletClientImpl
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.model.LightWalletEndpointInfoUnsafe
import co.electriccoin.lightwallet.client.model.RawTransactionUnsafe
import co.electriccoin.lightwallet.client.model.Response
import co.electriccoin.lightwallet.client.model.SendResponseUnsafe
// TODO [895]: Let remaining server calls adopt new response result
// TODO [895]: https://github.com/zcash/zcash-android-wallet-sdk/issues/895
/**
* Client for interacting with lightwalletd.
*/
interface BlockingLightWalletClient {
/**
* @return the full transaction info.
*/
fun fetchTransaction(txId: ByteArray): Response<RawTransactionUnsafe>
/**
* @param tAddress the transparent address to use.
* @param startHeight the starting height to use.
*
* @return the UTXOs for the given address from the [startHeight].
*
* @throws IllegalArgumentException when empty argument provided
*/
fun fetchUtxos(
tAddress: String,
startHeight: BlockHeightUnsafe
): Sequence<Service.GetAddressUtxosReply>
/**
* @param heightRange the inclusive range to fetch. For instance if 1..5 is given, then every
* block in that range will be fetched, including 1 and 5.
*
* @return a sequence of compact blocks for the given range
*
* @throws IllegalArgumentException when empty argument provided
*/
fun getBlockRange(heightRange: ClosedRange<BlockHeightUnsafe>): Sequence<CompactFormats.CompactBlock>
/**
* @return the latest block height known to the service.
*/
fun getLatestBlockHeight(): Response<BlockHeightUnsafe>
/**
* @return basic server information.
*/
fun getServerInfo(): Response<LightWalletEndpointInfoUnsafe>
/**
* 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
* indexed and responses are fairly quick.
*
* @return a sequence of transactions that correspond to the given address for the given range.
*
* @throws IllegalArgumentException when empty argument provided
*/
fun getTAddressTransactions(
tAddress: String,
blockHeightRange: ClosedRange<BlockHeightUnsafe>
): Sequence<Service.RawTransaction>
/**
* 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 reconnect()
/**
* Cleanup any connections when the service is shutting down and not going to be used again.
*/
fun shutdown()
/**
* Submit a raw transaction.
*
* @return the response from the server.
*/
fun submitTransaction(spendTransaction: ByteArray): Response<SendResponseUnsafe>
companion object
}
/**
* @return A new client specifically for Android devices.
*/
fun BlockingLightWalletClient.Companion.new(
context: Context,
lightWalletEndpoint: LightWalletEndpoint
): BlockingLightWalletClient =
BlockingLightWalletClientImpl.new(AndroidChannelFactory(context), lightWalletEndpoint)

View File

@ -0,0 +1,104 @@
package co.electriccoin.lightwallet.client
import android.content.Context
import cash.z.wallet.sdk.internal.rpc.CompactFormats
import cash.z.wallet.sdk.internal.rpc.Service
import co.electriccoin.lightwallet.client.internal.AndroidChannelFactory
import co.electriccoin.lightwallet.client.internal.CoroutineLightWalletClientImpl
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.model.LightWalletEndpointInfoUnsafe
import co.electriccoin.lightwallet.client.model.RawTransactionUnsafe
import co.electriccoin.lightwallet.client.model.Response
import co.electriccoin.lightwallet.client.model.SendResponseUnsafe
import kotlinx.coroutines.flow.Flow
// TODO [895]: Let remaining server calls adopt new response result
// TODO [895]: https://github.com/zcash/zcash-android-wallet-sdk/issues/895
/**
* Client for interacting with lightwalletd.
*/
interface CoroutineLightWalletClient {
/**
* @return the full transaction info.
*/
suspend fun fetchTransaction(txId: ByteArray): Response<RawTransactionUnsafe>
/**
* @param tAddress the transparent address to use.
* @param startHeight the starting height to use.
*
* @return a flow of UTXOs for the given address from the [startHeight].
*
* @throws IllegalArgumentException when empty argument provided
*/
suspend fun fetchUtxos(
tAddress: String,
startHeight: BlockHeightUnsafe
): Flow<Service.GetAddressUtxosReply>
/**
* @param heightRange the inclusive range to fetch. For instance if 1..5 is given, then every
* block in that range will be fetched, including 1 and 5.
*
* @return a flow of compact blocks for the given range
*
* @throws IllegalArgumentException when empty argument provided
*/
fun getBlockRange(heightRange: ClosedRange<BlockHeightUnsafe>): Flow<CompactFormats.CompactBlock>
/**
* @return the latest block height known to the service.
*/
suspend fun getLatestBlockHeight(): Response<BlockHeightUnsafe>
/**
* @return useful server details.
*/
suspend fun getServerInfo(): Response<LightWalletEndpointInfoUnsafe>
/**
* 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
* indexed and responses are fairly quick.
*
* @return a flow of transactions that correspond to the given address for the given range.
*
* @throws IllegalArgumentException when empty argument provided
*/
fun getTAddressTransactions(
tAddress: String,
blockHeightRange: ClosedRange<BlockHeightUnsafe>
): Flow<Service.RawTransaction>
/**
* 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 reconnect()
/**
* Cleanup any connections when the service is shutting down and not going to be used again.
*/
fun shutdown()
/**
* Submit a raw transaction.
*
* @return the response from the server.
*/
suspend fun submitTransaction(spendTransaction: ByteArray): Response<SendResponseUnsafe>
companion object
}
/**
* @return A new client specifically for Android devices.
*/
fun CoroutineLightWalletClient.Companion.new(
context: Context,
lightWalletEndpoint: LightWalletEndpoint
): CoroutineLightWalletClient =
CoroutineLightWalletClientImpl.new(AndroidChannelFactory(context), lightWalletEndpoint)

View File

@ -1,6 +1,6 @@
package cash.z.ecc.android.sdk.ext
package co.electriccoin.lightwallet.client.ext
import cash.z.ecc.android.sdk.BuildConfig
import co.electriccoin.lightwallet.client.BuildConfig
object BenchmarkingExt {
private const val TARGET_BUILD_TYPE = "benchmark" // NON-NLS

View File

@ -0,0 +1,25 @@
package co.electriccoin.lightwallet.client.fixture
import androidx.annotation.VisibleForTesting
object BlockRangeFixture {
// Be aware that changing these bounds values in a broader range may result in a timeout reached in
// SyncBlockchainBenchmark. So if changing these, don't forget to align also the test timeout in
// waitForBalanceScreen() appropriately.
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Suppress("MagicNumber")
internal val BLOCK_HEIGHT_LOWER_BOUND = 1730001L
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Suppress("MagicNumber")
internal val BLOCK_HEIGHT_UPPER_BOUND = 1730100L
fun new(
lowerBound: Long = BLOCK_HEIGHT_LOWER_BOUND,
upperBound: Long = BLOCK_HEIGHT_UPPER_BOUND
): ClosedRange<Long> {
return lowerBound..upperBound
}
}

View File

@ -0,0 +1,10 @@
package co.electriccoin.lightwallet.client.internal
import co.electriccoin.lightwallet.client.model.Response
/**
* This class provides conversion from API statuses to our predefined Server or Client error classes.
*/
interface ApiStatusResolver {
fun <T> resolveFailureFromStatus(throwable: Throwable): Response.Failure<T>
}

View File

@ -0,0 +1,192 @@
package co.electriccoin.lightwallet.client.internal
import cash.z.wallet.sdk.internal.rpc.CompactFormats
import cash.z.wallet.sdk.internal.rpc.CompactTxStreamerGrpc
import cash.z.wallet.sdk.internal.rpc.Service
import co.electriccoin.lightwallet.client.BlockingLightWalletClient
import co.electriccoin.lightwallet.client.ext.BenchmarkingExt
import co.electriccoin.lightwallet.client.fixture.BlockRangeFixture
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.model.LightWalletEndpointInfoUnsafe
import co.electriccoin.lightwallet.client.model.RawTransactionUnsafe
import co.electriccoin.lightwallet.client.model.Response
import co.electriccoin.lightwallet.client.model.SendResponseUnsafe
import com.google.protobuf.ByteString
import io.grpc.Channel
import io.grpc.ConnectivityState
import io.grpc.ManagedChannel
import io.grpc.StatusRuntimeException
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Implementation of BlockingLightWalletClient using gRPC for requests to lightwalletd.
*
* @property singleRequestTimeout 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 streamingRequestTimeout 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.
*/
internal class BlockingLightWalletClientImpl private constructor(
private val channelFactory: ChannelFactory,
private val lightWalletEndpoint: LightWalletEndpoint,
private val singleRequestTimeout: Duration = 10.seconds,
private val streamingRequestTimeout: Duration = 90.seconds
) : BlockingLightWalletClient {
private var channel = channelFactory.newChannel(lightWalletEndpoint)
override fun getBlockRange(heightRange: ClosedRange<BlockHeightUnsafe>): Sequence<CompactFormats.CompactBlock> {
require(!heightRange.isEmpty()) {
"${Constants.ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE} range: $heightRange." // NON-NLS
}
return requireChannel().createStub(streamingRequestTimeout)
.getBlockRange(heightRange.toBlockRange()).iterator().asSequence()
}
override fun getLatestBlockHeight(): Response<BlockHeightUnsafe> {
return try {
if (BenchmarkingExt.isBenchmarking()) {
// We inject a benchmark test blocks range at this point to process only a restricted range of blocks
// for a more reliable benchmark results.
Response.Success(BlockHeightUnsafe(BlockRangeFixture.new().endInclusive))
} else {
val response = requireChannel().createStub(singleRequestTimeout)
.getLatestBlock(Service.ChainSpec.newBuilder().build())
val blockHeight = BlockHeightUnsafe(response.height)
Response.Success(blockHeight)
}
} catch (e: StatusRuntimeException) {
GrpcStatusResolver.resolveFailureFromStatus(e)
}
}
@Suppress("SwallowedException")
override fun getServerInfo(): Response<LightWalletEndpointInfoUnsafe> {
return try {
val lightdInfo = requireChannel().createStub(singleRequestTimeout)
.getLightdInfo(Service.Empty.newBuilder().build())
val lightwalletEndpointInfo = LightWalletEndpointInfoUnsafe.new(lightdInfo)
Response.Success(lightwalletEndpointInfo)
} catch (e: StatusRuntimeException) {
GrpcStatusResolver.resolveFailureFromStatus(e)
}
}
override fun submitTransaction(spendTransaction: ByteArray): Response<SendResponseUnsafe> {
require(spendTransaction.isNotEmpty()) {
"${Constants.ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE} Failed to submit transaction because it was empty, so " +
"this request was ignored on the client-side." // NON-NLS
}
return try {
val request =
Service.RawTransaction.newBuilder().setData(ByteString.copyFrom(spendTransaction))
.build()
val response = requireChannel().createStub().sendTransaction(request)
val sendResponse = SendResponseUnsafe.new(response)
Response.Success(sendResponse)
} catch (e: StatusRuntimeException) {
GrpcStatusResolver.resolveFailureFromStatus(e)
}
}
override fun fetchTransaction(txId: ByteArray): Response<RawTransactionUnsafe> {
require(txId.isNotEmpty()) {
"${Constants.ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE} Failed to start fetching the transaction with null " +
"transaction ID, so this request was ignored on the client-side." // NON-NLS
}
return try {
val request = Service.TxFilter.newBuilder().setHash(ByteString.copyFrom(txId)).build()
val response = requireChannel().createStub().getTransaction(request)
val transactionResponse = RawTransactionUnsafe.new(response)
Response.Success(transactionResponse)
} catch (e: StatusRuntimeException) {
GrpcStatusResolver.resolveFailureFromStatus(e)
}
}
override fun fetchUtxos(
tAddress: String,
startHeight: BlockHeightUnsafe
): Sequence<Service.GetAddressUtxosReply> {
require(tAddress.isNotBlank()) {
"${Constants.ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE} address: $tAddress." // NON-NLS
}
val result = requireChannel().createStub().getAddressUtxos(
Service.GetAddressUtxosArg.newBuilder().setAddresses(0, tAddress)
.setStartHeight(startHeight.value).build()
)
return result.addressUtxosList.asSequence()
}
override fun getTAddressTransactions(
tAddress: String,
blockHeightRange: ClosedRange<BlockHeightUnsafe>
): Sequence<Service.RawTransaction> {
require(!blockHeightRange.isEmpty() && tAddress.isNotBlank()) {
"${Constants.ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE} range: $blockHeightRange, address: $tAddress." // NON-NLS
}
return requireChannel().createStub().getTaddressTxids(
Service.TransparentAddressBlockFilter.newBuilder().setAddress(tAddress)
.setRange(blockHeightRange.toBlockRange()).build()
).iterator().asSequence()
}
override fun shutdown() {
channel.shutdown()
}
override fun reconnect() {
channel.shutdown()
channel = channelFactory.newChannel(lightWalletEndpoint)
}
// These make the implementation of BlockingLightWalletClientImpl not thread-safe.
private var stateCount = 0
private var state: ConnectivityState? = null
private fun requireChannel(): ManagedChannel {
state = channel.getState(false).let { new ->
if (state == new) stateCount++ else stateCount = 0
new
}
channel.resetConnectBackoff()
return channel
}
companion object {
fun new(
channelFactory: ChannelFactory,
lightWalletEndpoint: LightWalletEndpoint
): BlockingLightWalletClient {
return BlockingLightWalletClientImpl(channelFactory, lightWalletEndpoint)
}
}
}
private fun Channel.createStub(timeoutSec: Duration = 60.seconds) =
CompactTxStreamerGrpc.newBlockingStub(this)
.withDeadlineAfter(timeoutSec.inWholeSeconds, TimeUnit.SECONDS)
private fun BlockHeightUnsafe.toBlockHeight(): Service.BlockID =
Service.BlockID.newBuilder().setHeight(value).build()
private fun ClosedRange<BlockHeightUnsafe>.toBlockRange(): Service.BlockRange =
Service.BlockRange.newBuilder()
.setStart(start.toBlockHeight())
.setEnd(endInclusive.toBlockHeight())
.build()

View File

@ -0,0 +1,30 @@
package co.electriccoin.lightwallet.client.internal
import android.util.Log
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import io.grpc.ManagedChannel
import io.grpc.android.AndroidChannelBuilder
internal interface ChannelFactory {
fun newChannel(endpoint: LightWalletEndpoint): ManagedChannel
}
internal class AndroidChannelFactory(context: android.content.Context) : ChannelFactory {
private val context = context.applicationContext
override fun newChannel(endpoint: LightWalletEndpoint): ManagedChannel {
return AndroidChannelBuilder
.forAddress(endpoint.host, endpoint.port)
.context(context)
.enableFullStreamDecompression()
.apply {
if (endpoint.isSecure) {
useTransportSecurity()
} else {
Log.w(Constants.LOG_TAG, "WARNING Using plaintext connection")
usePlaintext()
}
}
.build()
}
}

View File

@ -0,0 +1,6 @@
package co.electriccoin.lightwallet.client.internal
internal object Constants {
const val LOG_TAG = "LightWalletClient" // NON-NLS
const val ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE = "Illegal argument provided - can't be empty:" // NON-NLS
}

View File

@ -0,0 +1,192 @@
package co.electriccoin.lightwallet.client.internal
import cash.z.wallet.sdk.internal.rpc.CompactFormats
import cash.z.wallet.sdk.internal.rpc.CompactTxStreamerGrpcKt
import cash.z.wallet.sdk.internal.rpc.Service
import co.electriccoin.lightwallet.client.CoroutineLightWalletClient
import co.electriccoin.lightwallet.client.ext.BenchmarkingExt
import co.electriccoin.lightwallet.client.fixture.BlockRangeFixture
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.model.LightWalletEndpointInfoUnsafe
import co.electriccoin.lightwallet.client.model.RawTransactionUnsafe
import co.electriccoin.lightwallet.client.model.Response
import co.electriccoin.lightwallet.client.model.SendResponseUnsafe
import com.google.protobuf.ByteString
import io.grpc.CallOptions
import io.grpc.Channel
import io.grpc.ConnectivityState
import io.grpc.ManagedChannel
import io.grpc.StatusRuntimeException
import kotlinx.coroutines.flow.Flow
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Implementation of CoroutineLightWalletClient using gRPC for requests to lightwalletd.
*
* @property singleRequestTimeout 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 streamingRequestTimeout 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.
*/
internal class CoroutineLightWalletClientImpl private constructor(
private val channelFactory: ChannelFactory,
private val lightWalletEndpoint: LightWalletEndpoint,
private val singleRequestTimeout: Duration = 10.seconds,
private val streamingRequestTimeout: Duration = 90.seconds
) : CoroutineLightWalletClient {
private var channel = channelFactory.newChannel(lightWalletEndpoint)
override fun getBlockRange(heightRange: ClosedRange<BlockHeightUnsafe>): Flow<CompactFormats.CompactBlock> {
require(!heightRange.isEmpty()) {
"${Constants.ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE} range: $heightRange." // NON-NLS
}
return requireChannel().createStub(streamingRequestTimeout)
.getBlockRange(heightRange.toBlockRange())
}
override suspend fun getLatestBlockHeight(): Response<BlockHeightUnsafe> {
return try {
if (BenchmarkingExt.isBenchmarking()) {
// We inject a benchmark test blocks range at this point to process only a restricted range of blocks
// for a more reliable benchmark results.
Response.Success(BlockHeightUnsafe(BlockRangeFixture.new().endInclusive))
} else {
val response = requireChannel().createStub(singleRequestTimeout)
.getLatestBlock(Service.ChainSpec.newBuilder().build())
val blockHeight = BlockHeightUnsafe(response.height)
Response.Success(blockHeight)
}
} catch (e: StatusRuntimeException) {
GrpcStatusResolver.resolveFailureFromStatus(e)
}
}
@Suppress("SwallowedException")
override suspend fun getServerInfo(): Response<LightWalletEndpointInfoUnsafe> {
return try {
val lightdInfo = requireChannel().createStub(singleRequestTimeout)
.getLightdInfo(Service.Empty.newBuilder().build())
val lightwalletEndpointInfo = LightWalletEndpointInfoUnsafe.new(lightdInfo)
Response.Success(lightwalletEndpointInfo)
} catch (e: StatusRuntimeException) {
GrpcStatusResolver.resolveFailureFromStatus(e)
}
}
override suspend fun submitTransaction(spendTransaction: ByteArray): Response<SendResponseUnsafe> {
require(spendTransaction.isNotEmpty()) {
"${Constants.ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE} Failed to submit transaction because it was empty, so " +
"this request was ignored on the client-side." // NON-NLS
}
return try {
val request =
Service.RawTransaction.newBuilder().setData(ByteString.copyFrom(spendTransaction))
.build()
val response = requireChannel().createStub().sendTransaction(request)
val sendResponse = SendResponseUnsafe.new(response)
Response.Success(sendResponse)
} catch (e: StatusRuntimeException) {
GrpcStatusResolver.resolveFailureFromStatus(e)
}
}
override suspend fun fetchTransaction(txId: ByteArray): Response<RawTransactionUnsafe> {
require(txId.isNotEmpty()) {
"${Constants.ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE} Failed to start fetching the transaction with null " +
"transaction ID, so this request was ignored on the client-side." // NON-NLS
}
return try {
val request = Service.TxFilter.newBuilder().setHash(ByteString.copyFrom(txId)).build()
val response = requireChannel().createStub().getTransaction(request)
val transactionResponse = RawTransactionUnsafe.new(response)
Response.Success(transactionResponse)
} catch (e: StatusRuntimeException) {
GrpcStatusResolver.resolveFailureFromStatus(e)
}
}
override suspend fun fetchUtxos(
tAddress: String,
startHeight: BlockHeightUnsafe
): Flow<Service.GetAddressUtxosReply> {
require(tAddress.isNotBlank()) {
"${Constants.ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE} address: $tAddress." // NON-NLS
}
return requireChannel().createStub().getAddressUtxosStream(
Service.GetAddressUtxosArg.newBuilder().setAddresses(0, tAddress)
.setStartHeight(startHeight.value).build()
)
}
override fun getTAddressTransactions(
tAddress: String,
blockHeightRange: ClosedRange<BlockHeightUnsafe>
): Flow<Service.RawTransaction> {
require(!blockHeightRange.isEmpty() && tAddress.isNotBlank()) {
"${Constants.ILLEGAL_ARGUMENT_EXCEPTION_MESSAGE} range: $blockHeightRange, address: $tAddress." // NON-NLS
}
return requireChannel().createStub().getTaddressTxids(
Service.TransparentAddressBlockFilter.newBuilder().setAddress(tAddress)
.setRange(blockHeightRange.toBlockRange()).build()
)
}
override fun shutdown() {
channel.shutdown()
}
override fun reconnect() {
channel.shutdown()
channel = channelFactory.newChannel(lightWalletEndpoint)
}
// These make the CoroutineLightWalletClientImpl not thread safe. In the long-term, we should
// consider making it thread safe.
private var stateCount = 0
private var state: ConnectivityState? = null
private fun requireChannel(): ManagedChannel {
state = channel.getState(false).let { new ->
if (state == new) stateCount++ else stateCount = 0
new
}
channel.resetConnectBackoff()
return channel
}
companion object {
fun new(
channelFactory: ChannelFactory,
lightWalletEndpoint: LightWalletEndpoint
): CoroutineLightWalletClientImpl {
return CoroutineLightWalletClientImpl(channelFactory, lightWalletEndpoint)
}
}
}
private fun Channel.createStub(timeoutSec: Duration = 60.seconds) =
CompactTxStreamerGrpcKt.CompactTxStreamerCoroutineStub(this, CallOptions.DEFAULT)
.withDeadlineAfter(timeoutSec.inWholeSeconds, TimeUnit.SECONDS)
private fun BlockHeightUnsafe.toBlockHeight(): Service.BlockID =
Service.BlockID.newBuilder().setHeight(value).build()
private fun ClosedRange<BlockHeightUnsafe>.toBlockRange(): Service.BlockRange =
Service.BlockRange.newBuilder()
.setStart(start.toBlockHeight())
.setEnd(endInclusive.toBlockHeight())
.build()

View File

@ -1,47 +1,46 @@
package cash.z.ecc.android.sdk.darkside.test
package co.electriccoin.lightwallet.client.internal
import android.content.Context
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.Darkside
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.wallet.sdk.rpc.Darkside
import cash.z.wallet.sdk.rpc.Darkside.DarksideTransactionsURL
import cash.z.wallet.sdk.rpc.DarksideStreamerGrpc
import cash.z.wallet.sdk.rpc.Service
import cash.z.wallet.sdk.internal.rpc.Darkside
import cash.z.wallet.sdk.internal.rpc.Darkside.DarksideTransactionsURL
import cash.z.wallet.sdk.internal.rpc.DarksideStreamerGrpc
import cash.z.wallet.sdk.internal.rpc.Service
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import io.grpc.ManagedChannel
import io.grpc.stub.StreamObserver
import java.lang.RuntimeException
import java.util.concurrent.TimeUnit
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class DarksideApi(
/*
* This class is under the internal package, but is itself not restricted with internal visibility.
*
* This allows the class to be used for some automated tests in other modules.
*/
class DarksideApi private constructor(
private val channel: ManagedChannel,
private val singleRequestTimeoutSec: Long = 10L
private val singleRequestTimeout: Duration = 10.seconds
) {
constructor(
appContext: Context,
lightWalletEndpoint: LightWalletEndpoint
) : this(
LightWalletGrpcService.createDefaultChannel(
appContext,
lightWalletEndpoint
)
)
companion object {
internal fun new(
channelFactory: ChannelFactory,
lightWalletEndpoint: LightWalletEndpoint
) = DarksideApi(channelFactory.newChannel(lightWalletEndpoint))
}
//
// Service APIs
//
fun reset(
saplingActivationHeight: BlockHeight = ZcashNetwork.Mainnet.saplingActivationHeight,
saplingActivationHeight: BlockHeightUnsafe,
branchId: String = "e9ff75a6", // Canopy,
chainName: String = "darkside${ZcashNetwork.Mainnet.networkName}"
chainName: String = "darksidemainnet"
) = apply {
twig("resetting darksidewalletd with saplingActivation=$saplingActivationHeight branchId=$branchId chainName=$chainName")
Darkside.DarksideMetaState.newBuilder()
.setBranchID(branchId)
.setChainName(chainName)
@ -52,51 +51,49 @@ class DarksideApi(
}
fun stageBlocks(url: String) = apply {
twig("staging blocks url=$url")
createStub().stageBlocks(url.toUrl())
}
fun stageTransactions(url: String, targetHeight: BlockHeight) = apply {
twig("staging transaction at height=$targetHeight from url=$url")
fun stageTransactions(url: String, targetHeight: BlockHeightUnsafe) = apply {
createStub().stageTransactions(
DarksideTransactionsURL.newBuilder().setHeight(targetHeight.value).setUrl(url).build()
DarksideTransactionsURL.newBuilder().setHeight(targetHeight.value.toInt()).setUrl(url).build()
)
}
fun stageEmptyBlocks(startHeight: BlockHeight, count: Int = 10, nonce: Int = Random.nextInt()) = apply {
twig("staging $count empty blocks starting at $startHeight with nonce $nonce")
fun stageEmptyBlocks(
startHeight: BlockHeightUnsafe,
count: Int = 10,
nonce: Int = Random.nextInt()
) = apply {
createStub().stageBlocksCreate(
Darkside.DarksideEmptyBlocks.newBuilder().setHeight(startHeight.value).setCount(count).setNonce(nonce).build()
Darkside.DarksideEmptyBlocks.newBuilder().setHeight(startHeight.value.toInt()).setCount(count)
.setNonce(nonce).build()
)
}
fun stageTransactions(txs: Iterator<Service.RawTransaction>?, tipHeight: BlockHeight) {
fun stageTransactions(txs: Iterator<Service.RawTransaction>?, tipHeight: BlockHeightUnsafe) {
if (txs == null) {
twig("no transactions to stage")
return
}
twig("staging transaction at height=$tipHeight")
val response = EmptyResponse()
createStreamingStub().stageTransactionsStream(response).apply {
txs.forEach {
twig("stageTransactions: onNext calling!!!")
onNext(it.newBuilderForType().setData(it.data).setHeight(tipHeight.value).build()) // apply the tipHeight because the passed in txs might not know their destination height (if they were created via SendTransaction)
twig("stageTransactions: onNext called")
// apply the tipHeight because the passed in txs might not know their destination
// height (if they were created via SendTransaction)
onNext(
it.newBuilderForType().setData(it.data).setHeight(tipHeight.value).build()
)
}
twig("stageTransactions: onCompleted calling!!!")
onCompleted()
twig("stageTransactions: onCompleted called")
}
response.await()
}
fun applyBlocks(tipHeight: BlockHeight) {
twig("applying blocks up to tipHeight=$tipHeight")
fun applyBlocks(tipHeight: BlockHeightUnsafe) {
createStub().applyStaged(tipHeight.toHeight())
}
fun getSentTransactions(): MutableIterator<Service.RawTransaction>? {
twig("grabbing sent transactions...")
return createStub().getIncomingTransactions(Service.Empty.newBuilder().build())
}
// fun setMetaState(
@ -121,7 +118,7 @@ class DarksideApi(
// fun setState(latestHeight: Int = -1, reorgHeight: Int = latestHeight): DarksideApi {
// this.latestHeight = latestHeight
// this.reorgHeight = reorgHeight
// // TODO: change this service to accept ints as heights, like everywhere else
// // change this service to accept ints as heights, like everywhere else
// createStub().darksideSetState(
// Darkside.DarksideState.newBuilder()
// .setLatestHeight(latestHeight.toLong())
@ -134,40 +131,50 @@ class DarksideApi(
private fun createStub(): DarksideStreamerGrpc.DarksideStreamerBlockingStub =
DarksideStreamerGrpc
.newBlockingStub(channel)
.withDeadlineAfter(singleRequestTimeoutSec, TimeUnit.SECONDS)
.withDeadlineAfter(singleRequestTimeout.inWholeSeconds, TimeUnit.SECONDS)
private fun createStreamingStub(): DarksideStreamerGrpc.DarksideStreamerStub =
DarksideStreamerGrpc
.newStub(channel)
.withDeadlineAfter(singleRequestTimeoutSec, TimeUnit.SECONDS)
.withDeadlineAfter(singleRequestTimeout.inWholeSeconds, TimeUnit.SECONDS)
private fun String.toUrl() = Darkside.DarksideBlocksURL.newBuilder().setUrl(this).build()
private fun BlockHeight.toHeight() = Darkside.DarksideHeight.newBuilder().setHeight(this.value).build()
class EmptyResponse : StreamObserver<Service.Empty> {
companion object {
private val DEFAULT_DELAY = 20.milliseconds
}
var completed = false
var error: Throwable? = null
override fun onNext(value: Service.Empty?) {
twig("<><><><><><><><> EMPTY RESPONSE: ONNEXT CALLED!!!!")
// No implementation
}
override fun onError(t: Throwable?) {
twig("<><><><><><><><> EMPTY RESPONSE: ONERROR CALLED!!!!")
error = t
completed = true
}
override fun onCompleted() {
twig("<><><><><><><><> EMPTY RESPONSE: ONCOMPLETED CALLED!!!")
completed = true
}
fun await() {
while (!completed) {
twig("awaiting server response...")
Thread.sleep(20L)
Thread.sleep(DEFAULT_DELAY.inWholeSeconds)
}
if (error != null) {
error("Server responded with an error: $error caused by ${error?.cause}")
}
if (error != null) throw RuntimeException("Server responded with an error: $error caused by ${error?.cause}")
}
}
}
private fun BlockHeightUnsafe.toHeight() =
Darkside.DarksideHeight.newBuilder().setHeight(this.value.toInt()).build()
fun DarksideApi.Companion.new(
context: Context,
lightWalletEndpoint: LightWalletEndpoint
) = DarksideApi.new(AndroidChannelFactory(context), lightWalletEndpoint)

View File

@ -0,0 +1,67 @@
package co.electriccoin.lightwallet.client.internal
import android.util.Log
import co.electriccoin.lightwallet.client.model.Response
import io.grpc.Status
/**
* This class provides conversion from GRPC Status to our predefined Server or Client error classes.
*/
object GrpcStatusResolver : ApiStatusResolver {
override fun <T> resolveFailureFromStatus(throwable: Throwable): Response.Failure<T> {
val status = Status.fromThrowable(throwable)
Log.w(Constants.LOG_TAG, "Networking error: ${status.code}: ${status.description}")
return when (status.code) {
Status.Code.ABORTED -> {
Response.Failure.Server.Aborted(
code = status.code.value(),
description = status.description
)
}
Status.Code.CANCELLED -> {
Response.Failure.Client.Canceled(
code = status.code.value(),
description = status.description
)
}
Status.Code.DEADLINE_EXCEEDED -> {
Response.Failure.Server.DeadlineExceeded(
code = status.code.value(),
description = status.description
)
}
Status.Code.NOT_FOUND -> {
Response.Failure.Server.NotFound(
code = status.code.value(),
description = status.description
)
}
Status.Code.PERMISSION_DENIED -> {
Response.Failure.Server.PermissionDenied(
code = status.code.value(),
description = status.description
)
}
Status.Code.UNAVAILABLE -> {
Response.Failure.Server.Unavailable(
code = status.code.value(),
description = status.description
)
}
Status.Code.UNKNOWN -> {
Response.Failure.Server.Unknown(
code = status.code.value(),
description = status.description
)
}
else -> {
Response.Failure.Server.Other(
code = status.code.value(),
description = status.description
)
}
}
}
}

View File

@ -0,0 +1,18 @@
package co.electriccoin.lightwallet.client.model
/**
* A Block Height that has come from the Light Wallet server.
*
* It is marked as "unsafe" because it is not guaranteed to be valid.
*/
data class BlockHeightUnsafe(val value: Long) : Comparable<BlockHeightUnsafe> {
init {
require(UINT_RANGE.contains(value)) { "Height $value is outside of allowed range $UINT_RANGE" }
}
override fun compareTo(other: BlockHeightUnsafe): Int = value.compareTo(other.value)
companion object {
private val UINT_RANGE = 0.toLong()..UInt.MAX_VALUE.toLong()
}
}

View File

@ -1,4 +1,4 @@
package cash.z.ecc.android.sdk.model
package co.electriccoin.lightwallet.client.model
data class LightWalletEndpoint(val host: String, val port: Int, val isSecure: Boolean) {
companion object

View File

@ -0,0 +1,23 @@
package co.electriccoin.lightwallet.client.model
import cash.z.wallet.sdk.internal.rpc.Service
/**
* A lightwalletd endpoint information, which has come from the Light Wallet server.
*
* It is marked as "unsafe" because it is not guaranteed to be valid.
*/
data class LightWalletEndpointInfoUnsafe(
val chainName: String,
val consensusBranchId: String,
val blockHeightUnsafe: BlockHeightUnsafe
) {
companion object {
internal fun new(lightdInfo: Service.LightdInfo) =
LightWalletEndpointInfoUnsafe(
lightdInfo.chainName,
lightdInfo.consensusBranchId,
BlockHeightUnsafe(lightdInfo.blockHeight)
)
}
}

View File

@ -0,0 +1,17 @@
package co.electriccoin.lightwallet.client.model
import cash.z.wallet.sdk.internal.rpc.Service.RawTransaction
/**
* RawTransaction contains the complete transaction data, which has come from the Light Wallet server.
*
* It is marked as "unsafe" because it is not guaranteed to be valid.
*/
class RawTransactionUnsafe(val height: BlockHeightUnsafe, val data: ByteArray) {
companion object {
fun new(rawTransaction: RawTransaction) = RawTransactionUnsafe(
BlockHeightUnsafe(rawTransaction.height),
rawTransaction.data.toByteArray()
)
}
}

View File

@ -0,0 +1,103 @@
package co.electriccoin.lightwallet.client.model
internal const val CONNECTION_ERROR_CODE = 3100
internal const val CONNECTION_ERROR_DESCRIPTION = "Missing internet connection." // NON-NLS
sealed class Response<T> {
data class Success<T>(val result: T) : Response<T>()
sealed class Failure<T>(
open val code: Int,
open val description: String?
) : Response<T>() {
/**
* Use this function to convert Failure into Throwable object.
*/
fun toThrowable() =
Throwable("Communication failure with details: $code${description?.let{": $it"} ?: "."}") // NON-NLS
/**
* The client was not able to communicate with the server.
*/
class Connection<T>(
override val description: String? = CONNECTION_ERROR_DESCRIPTION
) : Failure<T>(CONNECTION_ERROR_CODE, description) {
override fun toString(): String {
return "Connection Error(code='$code', description='$description')" // NON-NLS
}
}
/**
* The server did respond and returned an error.
*/
sealed class Server<T>(
override val code: Int,
override val description: String?
) : Failure<T>(code, description) {
/**
* The operation was aborted, typically due to a concurrency issue like sequencer check failures,
* transaction aborts, etc.
*/
class Aborted<T>(code: Int, description: String?) : Server<T>(code, description)
/**
* Deadline expired before operation could complete. For operations that change the state of the system,
* this error may be returned even if the operation has completed successfully. For example, a
* successful response from a server could have been delayed long enough for the deadline to expire.
*/
class DeadlineExceeded<T>(code: Int, description: String?) : Server<T>(code, description)
/**
* Some requested entity (e.g., file or directory) was not found.
*/
class NotFound<T>(code: Int, description: String?) : Server<T>(code, description)
/**
* This state covers errors like ALREADY_EXISTS, FAILED_PRECONDITION, DATA_LOSS, INTERNAL, INVALID_ARGUMENT,
* OUT_OF_RANGE, RESOURCE_EXHAUSTED, UNAUTHENTICATED or UNIMPLEMENTED. You find about these errors in
* {@link io.grpc.Status.Code}.
*/
class Other<T>(code: Int, description: String?) : Server<T>(code, description)
/**
* The caller does not have permission to execute the specified operation.
*/
class PermissionDenied<T>(code: Int, description: String?) : Server<T>(code, description)
/**
* The service is currently unavailable. This is a most likely a transient condition and may be
* corrected by retrying with a backoff. Note that it is not always safe to retry non-idempotent operations.
*/
class Unavailable<T>(code: Int, description: String?) : Server<T>(code, description)
/**
* Unknown error. An example of where this error may be returned is if a Status value received from
* another address space belongs to an error-space that is not known in this address space. Also errors
* raised by APIs that do not return enough error information may be converted to this error.
*/
class Unknown<T>(code: Int, description: String?) : Server<T>(code, description)
override fun toString(): String {
return "Server Error(code='$code', description='$description')" // NON-NLS
}
}
/**
* A failure occurred on the client, such as parsing a response failed.
*/
sealed class Client<T>(
override val code: Int,
override val description: String?
) : Failure<T>(code, description) {
/**
* The operation was cancelled (typically by the caller).
*/
class Canceled<T>(code: Int, description: String?) : Client<T>(code, description)
override fun toString(): String {
return "Client Error(code='$code', description='$description')" // NON-NLS
}
}
}
}

View File

@ -0,0 +1,23 @@
package co.electriccoin.lightwallet.client.model
import cash.z.wallet.sdk.internal.rpc.Service
/**
* A SendResponse encodes an error code and a string. It is currently used only by SendTransaction(). If error code
* is zero, the operation was successful; if non-zero, it and the message specify the failure. It has come from the
* Light Wallet server.
*
* It is marked as "unsafe" because it is not guaranteed to be valid.
*/
data class SendResponseUnsafe(
val code: Int,
val message: String
) {
companion object {
internal fun new(sendResponse: Service.SendResponse) =
SendResponseUnsafe(
code = sendResponse.errorCode,
message = sendResponse.errorMessage
)
}
}

View File

@ -0,0 +1,67 @@
// Copyright (c) 2019-2020 The Zcash developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or https://www.opensource.org/licenses/mit-license.php .
syntax = "proto3";
package cash.z.wallet.sdk.rpc;
option java_package = "cash.z.wallet.sdk.internal.rpc";
option go_package = "lightwalletd/walletrpc";
option swift_prefix = "";
// Remember that proto3 fields are all optional. A field that is not present will be set to its zero value.
// bytes fields of hashes are in canonical little-endian format.
// CompactBlock is a packaging of ONLY the data from a block that's needed to:
// 1. Detect a payment to your shielded Sapling address
// 2. Detect a spend of your shielded Sapling notes
// 3. Update your witnesses to generate new Sapling spend proofs.
message CompactBlock {
uint32 protoVersion = 1; // the version of this wire format, for storage
uint64 height = 2; // the height of this block
bytes hash = 3; // the ID (hash) of this block, same as in block explorers
bytes prevHash = 4; // the ID (hash) of this block's predecessor
uint32 time = 5; // Unix epoch time when the block was mined
bytes header = 6; // (hash, prevHash, and time) OR (full header)
repeated CompactTx vtx = 7; // zero or more compact transactions from this block
}
// CompactTx contains the minimum information for a wallet to know if this transaction
// is relevant to it (either pays to it or spends from it) via shielded elements
// only. This message will not encode a transparent-to-transparent transaction.
message CompactTx {
uint64 index = 1; // the index within the full block
bytes hash = 2; // the ID (hash) of this transaction, same as in block explorers
// The transaction fee: present if server can provide. In the case of a
// stateless server and a transaction with transparent inputs, this will be
// unset because the calculation requires reference to prior transactions.
// in a pure-Sapling context, the fee will be calculable as:
// valueBalance + (sum(vPubNew) - sum(vPubOld) - sum(tOut))
uint32 fee = 3;
repeated CompactSaplingSpend spends = 4; // inputs
repeated CompactSaplingOutput outputs = 5; // outputs
repeated CompactOrchardAction actions = 6;
}
// CompactSaplingSpend is a Sapling Spend Description as described in 7.3 of the Zcash
// protocol specification.
message CompactSaplingSpend {
bytes nf = 1; // nullifier (see the Zcash protocol specification)
}
// output is a Sapling Output Description as described in section 7.4 of the
// Zcash protocol spec. Total size is 948.
message CompactSaplingOutput {
bytes cmu = 1; // note commitment u-coordinate
bytes epk = 2; // ephemeral public key
bytes ciphertext = 3; // first 52 bytes of ciphertext
}
// https://github.com/zcash/zips/blob/main/zip-0225.rst#orchard-action-description-orchardaction
// (but not all fields are needed)
message CompactOrchardAction {
bytes nullifier = 1; // [32] The nullifier of the input note
bytes cmx = 2; // [32] The x-coordinate of the note commitment for the output note
bytes ephemeralKey = 3; // [32] An encoding of an ephemeral Pallas public key
bytes ciphertext = 4; // [52] The note plaintext component of the encCiphertext field
}

View File

@ -4,7 +4,8 @@
syntax = "proto3";
package cash.z.wallet.sdk.rpc;
option go_package = ".;walletrpc";
option java_package = "cash.z.wallet.sdk.internal.rpc";
option go_package = "lightwalletd/walletrpc";
option swift_prefix = "";
import "service.proto";
@ -29,16 +30,16 @@ message DarksideBlocksURL {
// of hex-encoded transactions, one per line, that are to be associated
// with the given height (fake-mined into the block at that height)
message DarksideTransactionsURL {
int64 height = 1;
int32 height = 1;
string url = 2;
}
message DarksideHeight {
int64 height = 1;
int32 height = 1;
}
message DarksideEmptyBlocks {
int64 height = 1;
int32 height = 1;
int32 nonce = 2;
int32 count = 3;
}
@ -114,4 +115,20 @@ service DarksideStreamer {
// Clear the incoming transaction pool.
rpc ClearIncomingTransactions(Empty) returns (Empty) {}
// Add a GetAddressUtxosReply entry to be returned by GetAddressUtxos().
// There is no staging or applying for these, very simple.
rpc AddAddressUtxo(GetAddressUtxosReply) returns (Empty) {}
// Clear the list of GetAddressUtxos entries (can't fail)
rpc ClearAddressUtxo(Empty) returns (Empty) {}
// Adds a GetTreeState to the tree state cache
rpc AddTreeState(TreeState) returns (Empty) {}
// Removes a GetTreeState for the given height from cache if present (can't fail)
rpc RemoveTreeState(BlockID) returns (Empty) {}
// Clear the list of GetTreeStates entries (can't fail)
rpc ClearAllTreeStates(Empty) returns (Empty) {}
}

View File

@ -4,7 +4,8 @@
syntax = "proto3";
package cash.z.wallet.sdk.rpc;
option go_package = ".;walletrpc";
option java_package = "cash.z.wallet.sdk.internal.rpc";
option go_package = "lightwalletd/walletrpc";
option swift_prefix = "";
import "compact_formats.proto";
@ -32,7 +33,8 @@ message TxFilter {
}
// RawTransaction contains the complete transaction data. It also optionally includes
// the block height in which the transaction was included.
// the block height in which the transaction was included, or, when returned
// by GetMempoolStream(), the latest block height.
message RawTransaction {
bytes data = 1; // exact data returned by Zcash 'getrawtransaction'
uint64 height = 2; // height that the transaction was mined (or -1)
@ -109,19 +111,23 @@ message Exclude {
// The TreeState is derived from the Zcash z_gettreestate rpc.
message TreeState {
string network = 1; // "main" or "test"
uint64 height = 2;
string hash = 3; // block id
uint32 time = 4; // Unix epoch time when the block was mined
string tree = 5; // sapling commitment tree state
string network = 1; // "main" or "test"
uint64 height = 2; // block height
string hash = 3; // block id
uint32 time = 4; // Unix epoch time when the block was mined
string saplingTree = 5; // sapling commitment tree state
string orchardTree = 6; // orchard commitment tree state
}
// Results are sorted by height, which makes it easy to issue another
// request that picks up from where the previous left off.
message GetAddressUtxosArg {
string address = 1;
repeated string addresses = 1;
uint64 startHeight = 2;
uint32 maxEntries = 3; // zero means unlimited
}
message GetAddressUtxosReply {
string address = 6;
bytes txid = 1;
int32 index = 2;
bytes script = 3;
@ -161,17 +167,22 @@ service CompactTxStreamer {
// in the exclude list that don't exist in the mempool are ignored.
rpc GetMempoolTx(Exclude) returns (stream CompactTx) {}
// Return a stream of current Mempool transactions. This will keep the output stream open while
// there are mempool transactions. It will close the returned stream when a new block is mined.
rpc GetMempoolStream(Empty) returns (stream RawTransaction) {}
// GetTreeState returns the note commitment tree state corresponding to the given block.
// See section 3.7 of the Zcash protocol specification. It returns several other useful
// values also (even though they can be obtained using GetBlock).
// The block can be specified by either height or hash.
rpc GetTreeState(BlockID) returns (TreeState) {}
rpc GetLatestTreeState(Empty) returns (TreeState) {}
rpc GetAddressUtxos(GetAddressUtxosArg) returns (GetAddressUtxosReplyList) {}
rpc GetAddressUtxosStream(GetAddressUtxosArg) returns (stream GetAddressUtxosReply) {}
// Return information about this lightwalletd instance and the blockchain
rpc GetLightdInfo(Empty) returns (LightdInfo) {}
// Testing-only
// Testing-only, requires lightwalletd --ping-very-insecure (do not enable in production)
rpc Ping(Duration) returns (PingResponse) {}
}

View File

@ -1,10 +1,5 @@
import com.google.protobuf.gradle.generateProtoTasks
import com.google.protobuf.gradle.id
import com.google.protobuf.gradle.plugins
import com.google.protobuf.gradle.proto
import com.google.protobuf.gradle.protobuf
import com.google.protobuf.gradle.protoc
import java.util.Base64
import java.util.Locale
plugins {
id("com.android.library")
@ -14,7 +9,6 @@ plugins {
id("com.google.devtools.ksp")
id("org.jetbrains.kotlin.plugin.allopen")
id("org.jetbrains.dokka")
id("com.google.protobuf")
id("org.mozilla.rust-android-gradle.rust-android")
id("wtf.emulator.gradle")
@ -154,11 +148,6 @@ android {
}
}
sourceSets.getByName("main") {
java.srcDir("build/generated/source/grpc")
proto { srcDir("src/main/proto") }
}
kotlinOptions {
// Tricky: fix: By default, the kotlin_module name will not include the version (in classes.jar/META-INF).
// Instead it has a colon, which breaks compilation on Windows. This is one way to set it explicitly to the
@ -166,23 +155,6 @@ android {
freeCompilerArgs += listOf("-module-name", "$myArtifactId-${myVersion}_release")
}
packagingOptions {
resources.excludes.addAll(
listOf(
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/license.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/notice.txt",
"META-INF/ASL2.0",
"META-INF/LICENSE.md",
"META-INF/LICENSE-notice.md"
)
)
}
lint {
baseline = File("lint-baseline.xml")
}
@ -195,6 +167,27 @@ android {
}
}
androidComponents {
onVariants { variant ->
if (variant.name.toLowerCase(Locale.US).contains("release")) {
variant.packaging.resources.excludes.addAll(
listOf(
"META-INF/ASL2.0",
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE-notice.md",
"META-INF/LICENSE.md",
"META-INF/LICENSE.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/license.txt",
"META-INF/notice.txt"
)
)
}
}
}
allOpen {
// marker for classes that we want to be able to extend in debug builds for testing purposes
annotation("cash.z.ecc.android.sdk.annotation.OpenClass")
@ -210,30 +203,6 @@ tasks.dokkaHtml.configure {
}
}
protobuf {
//generatedFilesBaseDir = "$projectDir/src/generated/source/grpc"
protoc { artifact = libs.protoc.get().asCoordinateString() }
plugins {
id("grpc") {
artifact = libs.grpc.protoc.get().asCoordinateString()
}
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
id("java") {
option("lite")
}
}
task.plugins {
id("grpc") {
option("lite")
}
}
}
}
}
cargo {
module = "."
libname = "zcashwalletsdk"
@ -255,6 +224,8 @@ cargo {
}
dependencies {
api(projects.lightwalletClientLib)
implementation(libs.androidx.annotation)
implementation(libs.androidx.appcompat)
@ -278,10 +249,6 @@ dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
// grpc-java
implementation(libs.bundles.grpc)
compileOnly(libs.javax.annotation)
//
// Locked Versions
// these should be checked regularly and removed when possible
@ -296,7 +263,6 @@ dependencies {
testImplementation(libs.kotlin.reflect)
testImplementation(libs.kotlin.test)
testImplementation(libs.bundles.junit)
testImplementation(libs.grpc.testing)
// NOTE: androidTests will use JUnit4, while src/test/java tests will leverage Junit5
// Attempting to use JUnit5 via https://github.com/mannodermaus/android-junit5 was painful. The plugin configuration
@ -318,12 +284,6 @@ dependencies {
}
tasks {
getByName("preBuild").dependsOn(create("bugfixTask") {
doFirst {
mkdir("build/extracted-include-protos/main")
}
})
/*
* The Mozilla Rust Gradle plugin caches the native build data under the "target" directory,
* which does not normally get deleted during a clean. The following task and dependency solves

View File

@ -8,29 +8,32 @@ import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.onFirst
import cash.z.ecc.android.sdk.internal.TroubleshootingTwig
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.isSubmitSuccess
import cash.z.ecc.android.sdk.test.ScopedTest
import cash.z.ecc.android.sdk.tool.CheckpointTool
import cash.z.ecc.android.sdk.tool.DerivationTool
import co.electriccoin.lightwallet.client.BlockingLightWalletClient
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.model.Response
import co.electriccoin.lightwallet.client.new
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.BeforeClass
import org.junit.Ignore
import org.junit.Test
import java.util.concurrent.CountDownLatch
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class TestnetIntegrationTest : ScopedTest() {
@ -40,12 +43,13 @@ class TestnetIntegrationTest : ScopedTest() {
@Test
@Ignore("This test is broken")
fun testLatestBlockTest() {
val service = LightWalletGrpcService.new(
val service = BlockingLightWalletClient.new(
context,
lightWalletEndpoint
)
val height = service.getLatestBlockHeight()
assertTrue(height > saplingActivation)
assertTrue(height is Response.Success<BlockHeightUnsafe>)
assertTrue((height as Response.Success<BlockHeightUnsafe>).result.value > saplingActivation.value)
}
@Test
@ -81,8 +85,7 @@ class TestnetIntegrationTest : ScopedTest() {
}
assertTrue(
availableBalance!!.value > 0,
"No funds available when we expected a balance greater than zero!"
availableBalance!!.value > 0
)
}

View File

@ -6,33 +6,24 @@ import androidx.test.filters.SdkSuppress
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.annotation.MaintainedTest
import cash.z.ecc.android.sdk.annotation.TestPurpose
import cash.z.ecc.android.sdk.exception.LightWalletException.ChangeServerException.ChainInfoNotMatching
import cash.z.ecc.android.sdk.exception.LightWalletException.ChangeServerException.StatusException
import cash.z.ecc.android.sdk.internal.block.CompactBlockDownloader
import cash.z.ecc.android.sdk.internal.repository.CompactBlockRepository
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.service.LightWalletService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.Mainnet
import cash.z.ecc.android.sdk.model.Testnet
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.test.ScopedTest
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import co.electriccoin.lightwallet.client.BlockingLightWalletClient
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.model.Response
import co.electriccoin.lightwallet.client.new
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.Spy
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
@MaintainedTest(TestPurpose.REGRESSION)
@ -48,17 +39,16 @@ class ChangeServiceTest : ScopedTest() {
lateinit var mockBlockStore: CompactBlockRepository
var mockCloseable: AutoCloseable? = null
@Spy
val service = LightWalletGrpcService.new(context, lightWalletEndpoint)
val service = BlockingLightWalletClient.new(context, lightWalletEndpoint)
lateinit var downloader: CompactBlockDownloader
lateinit var otherService: LightWalletService
lateinit var otherService: BlockingLightWalletClient
@Before
fun setup() {
initMocks()
downloader = CompactBlockDownloader(service, mockBlockStore)
otherService = LightWalletGrpcService.new(context, eccEndpoint)
otherService = BlockingLightWalletClient.new(context, eccEndpoint)
}
@After
@ -79,99 +69,8 @@ class ChangeServiceTest : ScopedTest() {
twig(it)
}.getOrElse { return }
assertTrue(result > network.saplingActivationHeight)
}
assertTrue(result is Response.Success<BlockHeightUnsafe>)
@Test
fun testCleanSwitch() = runBlocking {
// Test the result, only if there is no server communication problem.
val result = runCatching {
downloader.changeService(otherService)
return@runCatching downloader.downloadBlockRange(
BlockHeight.new(network, 900_000)..BlockHeight.new(network, 901_000)
)
}.onFailure {
twig(it)
}.getOrElse { return@runBlocking }
assertEquals(1_001, result)
}
/**
* Repeatedly connect to servers and download a range of blocks. Switch part way through and
* verify that the servers change over, even while actively downloading.
*/
@Test
@Ignore("This test is broken")
fun testSwitchWhileActive() = runBlocking {
val start = BlockHeight.new(ZcashNetwork.Mainnet, 900_000)
val count = 5
val differentiators = mutableListOf<String>()
var initialValue = downloader.getServerInfo().buildUser
val job = testScope.launch {
repeat(count) {
differentiators.add(downloader.getServerInfo().buildUser)
twig("downloading from ${differentiators.last()}")
downloader.downloadBlockRange(start..(start + 100 * it))
delay(10L)
}
}
delay(30)
testScope.launch {
downloader.changeService(otherService)
}
job.join()
assertTrue(differentiators.count { it == initialValue } < differentiators.size)
assertEquals(count, differentiators.size)
}
@Test
fun testSwitchToInvalidServer() = runBlocking {
var caughtException: Throwable? = null
downloader.changeService(LightWalletGrpcService.new(context, LightWalletEndpoint("invalid.lightwalletd", 9087, true))) {
caughtException = it
}
// the test can continue only if there is no server communication problem
if (caughtException is StatusException) {
twig("Server communication problem while testing.")
return@runBlocking
}
assertNotNull("Using an invalid host should generate an exception.", caughtException)
assertTrue(
"Exception was of the wrong type.",
caughtException is StatusException
)
}
@Test
fun testSwitchToTestnetFails() = runBlocking {
var caughtException: Throwable? = null
downloader.changeService(LightWalletGrpcService.new(context, LightWalletEndpoint.Testnet)) {
caughtException = it
}
// the test can continue only if there is no server communication problem
if (caughtException is StatusException) {
twig("Server communication problem while testing.")
return@runBlocking
}
assertNotNull("Using an invalid host should generate an exception.", caughtException)
assertTrue(
"Exception was of the wrong type. Expected ${ChainInfoNotMatching::class.simpleName} but was ${caughtException!!::class.simpleName}",
caughtException is ChainInfoNotMatching
)
(caughtException as ChainInfoNotMatching).propertyNames.let { props ->
arrayOf("saplingActivationHeight", "chainName").forEach {
assertTrue(
"$it should be a non-matching property but properties were [$props]",
props.contains(it, true)
)
}
}
assertTrue((result as Response.Success<BlockHeightUnsafe>).result.value > network.saplingActivationHeight.value)
}
}

View File

@ -5,9 +5,9 @@ import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import kotlinx.coroutines.test.runTest
import java.util.UUID
import kotlin.test.Test

View File

@ -9,7 +9,6 @@ import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.db.commonDatabaseBuilder
import cash.z.ecc.android.sdk.internal.db.pending.PendingTransactionDb
import cash.z.ecc.android.sdk.internal.model.EncodedTransaction
import cash.z.ecc.android.sdk.internal.service.LightWalletService
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.FirstClassByteArray
@ -21,6 +20,7 @@ import cash.z.ecc.android.sdk.test.ScopedTest
import cash.z.ecc.android.sdk.test.getAppContext
import cash.z.ecc.fixture.DatabaseNameFixture
import cash.z.ecc.fixture.DatabasePathFixture
import co.electriccoin.lightwallet.client.BlockingLightWalletClient
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.stub
import kotlinx.coroutines.delay
@ -45,7 +45,7 @@ class PersistentTransactionManagerTest : ScopedTest() {
internal lateinit var mockEncoder: TransactionEncoder
@Mock
lateinit var mockService: LightWalletService
lateinit var mockService: BlockingLightWalletClient
private val pendingDbFile = File(
DatabasePathFixture.new(),

View File

@ -9,11 +9,11 @@ import cash.z.ecc.android.sdk.internal.ext.deleteSuspend
import cash.z.ecc.android.sdk.internal.model.Checkpoint
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
import cash.z.ecc.android.sdk.test.readFileLinesInFlow
import cash.z.ecc.android.sdk.tool.CheckpointTool
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking

View File

@ -7,9 +7,9 @@ import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.internal.TroubleshootingTwig
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.defaultForNetwork
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

View File

@ -6,17 +6,16 @@ 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.internal.Twig
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.Testnet
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.isPending
import cash.z.ecc.android.sdk.tool.DerivationTool
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.SupervisorJob
@ -73,7 +72,6 @@ class TestWallet(
seed = seed,
startHeight
) as SdkSynchronizer
val service = (synchronizer.processor.downloader.lightWalletService as LightWalletGrpcService)
val available get() = synchronizer.saplingBalances.value?.available
val unifiedAddress =

View File

@ -3,12 +3,15 @@ package cash.z.ecc.android.sdk.util
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.internal.TroubleshootingTwig
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.model.from
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.Mainnet
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.BlockingLightWalletClient
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.new
import org.junit.Ignore
import org.junit.Test
@ -16,7 +19,7 @@ class TransactionCounterUtil {
private val network = ZcashNetwork.Mainnet
private val context = InstrumentationRegistry.getInstrumentation().context
private val service = LightWalletGrpcService.new(context, LightWalletEndpoint.Mainnet)
private val service = BlockingLightWalletClient.new(context, LightWalletEndpoint.Mainnet)
init {
Twig.plant(TroubleshootingTwig())
@ -27,9 +30,16 @@ class TransactionCounterUtil {
fun testBlockSize() {
val sizes = mutableMapOf<Int, Int>()
service.getBlockRange(
BlockHeight.new(ZcashNetwork.Mainnet, 900_000)..BlockHeight.new(
ZcashNetwork.Mainnet,
910_000
BlockHeightUnsafe.from(
BlockHeight.new(
ZcashNetwork.Mainnet,
900_000
)
)..BlockHeightUnsafe.from(
BlockHeight.new(
ZcashNetwork.Mainnet,
910_000
)
)
).forEach { b ->
twig("h: ${b.header.size()}")
@ -47,9 +57,16 @@ class TransactionCounterUtil {
var totalOutputs = 0
var totalTxs = 0
service.getBlockRange(
BlockHeight.new(ZcashNetwork.Mainnet, 900_000)..BlockHeight.new(
ZcashNetwork.Mainnet,
950_000
BlockHeightUnsafe.from(
BlockHeight.new(
ZcashNetwork.Mainnet,
900_000
)
)..BlockHeightUnsafe.from(
BlockHeight.new(
ZcashNetwork.Mainnet,
950_000
)
)
).forEach { b ->
b.header.size()

View File

@ -1,4 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Required for downloading sapling params. -->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -31,8 +31,6 @@ import cash.z.ecc.android.sdk.internal.isEmpty
import cash.z.ecc.android.sdk.internal.model.Checkpoint
import cash.z.ecc.android.sdk.internal.repository.CompactBlockRepository
import cash.z.ecc.android.sdk.internal.repository.DerivedDataRepository
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.service.LightWalletService
import cash.z.ecc.android.sdk.internal.transaction.OutboundTransactionManager
import cash.z.ecc.android.sdk.internal.transaction.PersistentTransactionManager
import cash.z.ecc.android.sdk.internal.transaction.TransactionEncoder
@ -42,7 +40,6 @@ import cash.z.ecc.android.sdk.internal.twigTask
import cash.z.ecc.android.sdk.jni.RustBackend
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.PendingTransaction
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionRecipient
@ -62,8 +59,9 @@ 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.UnifiedFullViewingKey
import cash.z.wallet.sdk.rpc.Service
import io.grpc.ManagedChannel
import co.electriccoin.lightwallet.client.BlockingLightWalletClient
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.lightwallet.client.new
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -189,15 +187,6 @@ class SdkSynchronizer private constructor(
var coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
/**
* The channel that this Synchronizer uses to communicate with lightwalletd. In most cases, this
* should not be needed or used. Instead, APIs should be added to the synchronizer to
* enable the desired behavior. In the rare case, such as testing, it can be helpful to share
* the underlying channel to connect to the same service, and use other APIs
* (such as darksidewalletd) because channels are heavyweight.
*/
val channel: ManagedChannel get() = (processor.downloader.lightWalletService as LightWalletGrpcService).channel
//
// Balances
//
@ -335,13 +324,6 @@ class SdkSynchronizer private constructor(
}
}
/**
* Convenience function that exposes the underlying server information, like its name and
* consensus branch id. Most wallets should already have a different source of truth for the
* server(s) with which they operate.
*/
override suspend fun getServerInfo(): Service.LightdInfo = processor.downloader.getServerInfo()
override suspend fun getNearestRewindHeight(height: BlockHeight): BlockHeight =
processor.getNearestRewindHeight(height)
@ -737,7 +719,7 @@ class SdkSynchronizer private constructor(
}
override suspend fun validateConsensusBranch(): ConsensusMatchType {
val serverBranchId = tryNull { processor.downloader.getServerInfo().consensusBranchId }
val serverBranchId = tryNull { processor.downloader.getServerInfo()?.consensusBranchId }
val sdkBranchId = tryNull {
(txManager as PersistentTransactionManager).encoder.getConsensusBranchId()
}
@ -792,8 +774,8 @@ internal object DefaultSynchronizerFactory {
cacheDbFile
)
fun defaultService(context: Context, lightWalletEndpoint: LightWalletEndpoint): LightWalletService =
LightWalletGrpcService.new(context, lightWalletEndpoint)
fun defaultService(context: Context, lightWalletEndpoint: LightWalletEndpoint): BlockingLightWalletClient =
BlockingLightWalletClient.new(context, lightWalletEndpoint)
internal fun defaultEncoder(
rustBackend: RustBackend,
@ -802,7 +784,7 @@ internal object DefaultSynchronizerFactory {
): TransactionEncoder = WalletTransactionEncoder(rustBackend, saplingParamTool, repository)
fun defaultDownloader(
service: LightWalletService,
service: BlockingLightWalletClient,
blockStore: CompactBlockRepository
): CompactBlockDownloader = CompactBlockDownloader(service, blockStore)
@ -811,7 +793,7 @@ internal object DefaultSynchronizerFactory {
zcashNetwork: ZcashNetwork,
alias: String,
encoder: TransactionEncoder,
service: LightWalletService
service: BlockingLightWalletClient
): OutboundTransactionManager {
val databaseFile = DatabaseCoordinator.getInstance(context).pendingTransactionsDbFile(
zcashNetwork,

View File

@ -7,7 +7,6 @@ import cash.z.ecc.android.sdk.internal.SaplingParamTool
import cash.z.ecc.android.sdk.internal.db.DatabaseCoordinator
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.model.PendingTransaction
import cash.z.ecc.android.sdk.model.Transaction
import cash.z.ecc.android.sdk.model.TransactionOverview
@ -20,7 +19,7 @@ import cash.z.ecc.android.sdk.tool.CheckpointTool
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.android.sdk.type.ConsensusMatchType
import cash.z.wallet.sdk.rpc.Service
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking
@ -277,13 +276,6 @@ interface Synchronizer {
*/
suspend fun validateAddress(address: String): AddressType
/**
* Convenience function that exposes the underlying server information, like its name and
* consensus branch id. Most wallets should already have a different source of truth for the
* server(s) with which they operate and thereby not need this function.
*/
suspend fun getServerInfo(): Service.LightdInfo
/**
* Download all UTXOs for the given address and store any new ones in the database.
*

View File

@ -14,12 +14,10 @@ import cash.z.ecc.android.sdk.block.CompactBlockProcessor.State.Validating
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.MismatchedBranch
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.MismatchedNetwork
import cash.z.ecc.android.sdk.exception.InitializeException
import cash.z.ecc.android.sdk.exception.RustLayerException
import cash.z.ecc.android.sdk.ext.BatchMetrics
import cash.z.ecc.android.sdk.ext.BenchmarkingExt
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.ZcashSdk.DOWNLOAD_BATCH_SIZE
import cash.z.ecc.android.sdk.ext.ZcashSdk.MAX_BACKOFF_INTERVAL
@ -28,13 +26,14 @@ import cash.z.ecc.android.sdk.ext.ZcashSdk.POLL_INTERVAL
import cash.z.ecc.android.sdk.ext.ZcashSdk.RETRIES
import cash.z.ecc.android.sdk.ext.ZcashSdk.REWIND_DISTANCE
import cash.z.ecc.android.sdk.ext.ZcashSdk.SCAN_BATCH_SIZE
import cash.z.ecc.android.sdk.fixture.BlockRangeFixture
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.block.CompactBlockDownloader
import cash.z.ecc.android.sdk.internal.ext.retryUpTo
import cash.z.ecc.android.sdk.internal.ext.retryWithBackoff
import cash.z.ecc.android.sdk.internal.ext.toHexReversed
import cash.z.ecc.android.sdk.internal.isEmpty
import cash.z.ecc.android.sdk.internal.model.from
import cash.z.ecc.android.sdk.internal.model.toBlockHeight
import cash.z.ecc.android.sdk.internal.repository.DerivedDataRepository
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.internal.twigTask
@ -45,8 +44,13 @@ import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.wallet.sdk.rpc.Service
import io.grpc.StatusRuntimeException
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.wallet.sdk.internal.rpc.Service
import co.electriccoin.lightwallet.client.ext.BenchmarkingExt
import co.electriccoin.lightwallet.client.fixture.BlockRangeFixture
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpointInfoUnsafe
import co.electriccoin.lightwallet.client.model.Response
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
@ -216,8 +220,9 @@ class CompactBlockProcessor internal constructor(
delay(napTime)
}
BlockProcessingResult.NoBlocksToProcess, BlockProcessingResult.FailedEnhance -> {
val noWorkDone = currentInfo.lastDownloadRange?.isEmpty()
?: true && currentInfo.lastScanRange?.isEmpty() ?: true
val noWorkDone =
currentInfo.lastDownloadRange?.isEmpty() ?: true &&
currentInfo.lastScanRange?.isEmpty() ?: true
val summary = if (noWorkDone) {
"Nothing to process: no new blocks to download or scan"
} else {
@ -284,7 +289,7 @@ class CompactBlockProcessor internal constructor(
if (!updateRanges()) {
twig("Disconnection detected! Attempting to reconnect!")
setState(Disconnected)
downloader.lightWalletService.reconnect()
downloader.lightWalletClient.reconnect()
BlockProcessingResult.Reconnecting
} else if (currentInfo.lastDownloadRange.isEmpty() && currentInfo.lastScanRange.isEmpty()) {
setState(Scanned(currentInfo.lastScanRange))
@ -293,7 +298,12 @@ class CompactBlockProcessor internal constructor(
if (BenchmarkingExt.isBenchmarking()) {
// We inject a benchmark test blocks range at this point to process only a restricted range of blocks
// for a more reliable benchmark results.
val benchmarkBlockRange = BlockRangeFixture.new()
val benchmarkBlockRange = BlockRangeFixture.new().let {
// Convert range of Longs to range of BlockHeights
BlockHeight.new(ZcashNetwork.Mainnet, it.start)..(
BlockHeight.new(ZcashNetwork.Mainnet, it.endInclusive)
)
}
downloadNewBlocks(benchmarkBlockRange)
val error = validateAndScanNewBlocks(benchmarkBlockRange)
if (error != BlockProcessingResult.Success) {
@ -329,57 +339,65 @@ class CompactBlockProcessor internal constructor(
* @return true when the update succeeds.
*/
private suspend fun updateRanges(): Boolean = withContext(IO) {
try {
// TODO [#683]: rethink this and make it easier to understand what's happening. Can we reduce this
// so that we only work with actual changing info rather than periodic snapshots? Do we need
// to calculate these derived values every time?
// TODO [#683]: https://github.com/zcash/zcash-android-wallet-sdk/issues/683
ProcessorInfo(
networkBlockHeight = downloader.getLatestBlockHeight(),
lastScannedHeight = getLastScannedHeight(),
lastDownloadedHeight = getLastDownloadedHeight()?.let {
// This fetches the latest height each time this method is called, which can be very inefficient
// when downloading all of the blocks from the server
val networkBlockHeight = run {
val networkBlockHeightUnsafe =
when (val response = downloader.getLatestBlockHeight()) {
is Response.Success -> response.result
else -> null
}
runCatching { networkBlockHeightUnsafe?.toBlockHeight(network) }.getOrNull()
} ?: return@withContext false
// TODO [#683]: rethink this and make it easier to understand what's happening. Can we reduce this
// so that we only work with actual changing info rather than periodic snapshots? Do we need
// to calculate these derived values every time?
// TODO [#683]: https://github.com/zcash/zcash-android-wallet-sdk/issues/683
ProcessorInfo(
networkBlockHeight = networkBlockHeight,
lastScannedHeight = getLastScannedHeight(),
lastDownloadedHeight = getLastDownloadedHeight()?.let {
BlockHeight.new(
network,
max(
it.value,
lowerBoundHeight.value - 1
)
)
},
lastDownloadRange = null,
lastScanRange = null
).let { initialInfo ->
updateProgress(
networkBlockHeight = initialInfo.networkBlockHeight,
lastScannedHeight = initialInfo.lastScannedHeight,
lastDownloadedHeight = initialInfo.lastDownloadedHeight,
lastScanRange = if (
initialInfo.lastScannedHeight != null &&
initialInfo.networkBlockHeight != null
) {
initialInfo.lastScannedHeight + 1..initialInfo.networkBlockHeight
} else {
null
},
lastDownloadRange = if (initialInfo.networkBlockHeight != null) {
BlockHeight.new(
network,
max(
it.value,
lowerBoundHeight.value - 1
)
)
},
lastDownloadRange = null,
lastScanRange = null
).let { initialInfo ->
updateProgress(
networkBlockHeight = initialInfo.networkBlockHeight,
lastScannedHeight = initialInfo.lastScannedHeight,
lastDownloadedHeight = initialInfo.lastDownloadedHeight,
lastScanRange = if (
initialInfo.lastScannedHeight != null &&
initialInfo.networkBlockHeight != null
) {
initialInfo.lastScannedHeight + 1..initialInfo.networkBlockHeight
} else {
null
},
lastDownloadRange = if (initialInfo.networkBlockHeight != null) {
BlockHeight.new(
network,
buildList {
add(network.saplingActivationHeight.value)
initialInfo.lastDownloadedHeight?.let { add(it.value + 1) }
initialInfo.lastScannedHeight?.let { add(it.value + 1) }
}.max()
)..initialInfo.networkBlockHeight
} else {
null
}
)
}
true
} catch (t: StatusRuntimeException) {
twig("Warning: failed to update ranges due to $t caused by ${t.cause}")
false
buildList {
add(network.saplingActivationHeight.value)
initialInfo.lastDownloadedHeight?.let { add(it.value + 1) }
initialInfo.lastScannedHeight?.let { add(it.value + 1) }
}.max()
)..initialInfo.networkBlockHeight
} else {
null
}
)
}
true
}
/**
@ -451,49 +469,65 @@ class CompactBlockProcessor internal constructor(
private suspend fun enhanceHelper(id: Long, rawTransactionId: ByteArray, minedHeight: BlockHeight) {
twig("START: enhancing transaction (id:$id block:$minedHeight)")
runCatching {
downloader.fetchTransaction(rawTransactionId)
}.onSuccess { tx ->
tx?.let {
when (val response = downloader.fetchTransaction(rawTransactionId)) {
is Response.Success -> {
runCatching {
twig("decrypting and storing transaction (id:$id block:$minedHeight)")
rustBackend.decryptAndStoreTransaction(it.data.toByteArray())
rustBackend.decryptAndStoreTransaction(response.result.data)
}.onSuccess {
twig("DONE: enhancing transaction (id:$id block:$minedHeight)")
}.onFailure { error ->
onProcessorError(EnhanceTxDecryptError(minedHeight, error))
}
} ?: twig("no transaction found. Nothing to enhance. This probably shouldn't happen.")
}.onFailure { error ->
onProcessorError(EnhanceTxDownloadError(minedHeight, error))
}
is Response.Failure -> {
onProcessorError(EnhanceTxDownloadError(minedHeight, response.toThrowable()))
}
}
}
/**
* Confirm that the wallet data is properly setup for use.
*/
// Need to refactor this to be less ugly and more testable
@Suppress("NestedBlockDepth")
private suspend fun verifySetup() {
// verify that the data is initialized
var error = when {
!repository.isInitialized() -> CompactBlockProcessorException.Uninitialized
repository.getAccountCount() == 0 -> CompactBlockProcessorException.NoAccount
else -> {
// verify that the server is correct
downloader.getServerInfo().let { info ->
val clientBranch =
"%x".format(rustBackend.getBranchIdForHeight(BlockHeight(info.blockHeight)))
val error = if (!repository.isInitialized()) {
CompactBlockProcessorException.Uninitialized
} else if (repository.getAccountCount() == 0) {
CompactBlockProcessorException.NoAccount
} else {
// verify that the server is correct
// How do we handle network connection issues?
downloader.getServerInfo()?.let { info ->
val serverBlockHeight =
runCatching { info.blockHeightUnsafe.toBlockHeight(network) }.getOrNull()
if (null == serverBlockHeight) {
// TODO Better signal network connection issue
CompactBlockProcessorException.BadBlockHeight(info.blockHeightUnsafe)
} else {
val clientBranch = "%x".format(
Locale.ROOT,
rustBackend.getBranchIdForHeight(serverBlockHeight)
)
val network = rustBackend.network.networkName
when {
!info.matchingNetwork(network) -> MismatchedNetwork(
if (!clientBranch.equals(info.consensusBranchId, true)) {
MismatchedNetwork(
clientNetwork = network,
serverNetwork = info.chainName
)
!info.matchingConsensusBranchId(clientBranch) -> MismatchedBranch(
clientBranch = clientBranch,
serverBranch = info.consensusBranchId,
networkName = network
} else if (!info.matchingNetwork(network)) {
MismatchedNetwork(
clientNetwork = network,
serverNetwork = info.chainName
)
else -> null
} else {
null
}
}
}
@ -540,7 +574,10 @@ class CompactBlockProcessor internal constructor(
@Suppress("TooGenericExceptionCaught")
try {
retryUpTo(3) {
val result = downloader.lightWalletService.fetchUtxos(tAddress, startHeight)
val result = downloader.lightWalletClient.fetchUtxos(
tAddress,
BlockHeightUnsafe.from(startHeight)
)
count = processUtxoResult(result, tAddress, startHeight)
}
} catch (e: Throwable) {
@ -561,7 +598,7 @@ class CompactBlockProcessor internal constructor(
}
internal suspend fun processUtxoResult(
result: List<Service.GetAddressUtxosReply>,
result: Sequence<Service.GetAddressUtxosReply>,
tAddress: String,
startHeight: BlockHeight
): Int = withContext(IO) {
@ -601,7 +638,7 @@ class CompactBlockProcessor internal constructor(
}
}
// return the number of UTXOs that were downloaded
result.size - skipped
result.count() - skipped
}
/**
@ -854,12 +891,12 @@ class CompactBlockProcessor internal constructor(
if (alsoClearBlockCache) {
twig(
"Also clearing block cache back to $targetHeight. These rewound blocks will " +
"download in the next scheduled scan"
"Also clearing block cache back to $targetHeight. These rewound blocks will download " +
"in the next scheduled scan"
)
downloader.rewindToHeight(targetHeight)
// communicate that the wallet is no longer synced because it might remain this way for 20+
// seconds because we only download on 20s time boundaries so we can't trigger any immediate action
// communicate that the wallet is no longer synced because it might remain this way for 20+ second
// because we only download on 20s time boundaries so we can't trigger any immediate action
setState(Downloading)
if (null == currentNetworkBlockHeight) {
updateProgress(
@ -1256,21 +1293,6 @@ class CompactBlockProcessor internal constructor(
// Helper Extensions
//
private fun Service.LightdInfo.matchingConsensusBranchId(clientBranch: String): Boolean {
return consensusBranchId.equals(clientBranch, true)
}
private fun Service.LightdInfo.matchingNetwork(network: String): Boolean {
fun String.toId() = lowercase(Locale.US).run {
when {
contains("main") -> "mainnet"
contains("test") -> "testnet"
else -> this
}
}
return chainName.toId() == network.toId()
}
/**
* Log the mutex in great detail just in case we need it for troubleshooting deadlock.
*/
@ -1285,6 +1307,17 @@ 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()
}
private fun max(a: BlockHeight?, b: BlockHeight) = if (null == a) {
b
} else if (a.value > b.value) {

View File

@ -4,7 +4,8 @@ import cash.z.ecc.android.sdk.internal.SaplingParameters
import cash.z.ecc.android.sdk.internal.model.Checkpoint
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.wallet.sdk.rpc.Service
import cash.z.wallet.sdk.internal.rpc.Service
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import io.grpc.Status
import io.grpc.Status.Code.UNAVAILABLE
@ -138,6 +139,9 @@ sealed class CompactBlockProcessorException(message: String, cause: Throwable? =
"Incompatible server: this client expects a server following consensus branch $clientBranch on $networkName " +
"but it was $serverBranch! 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

@ -1,24 +0,0 @@
package cash.z.ecc.android.sdk.fixture
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
object BlockRangeFixture {
// Be aware that changing these bounds values in a broader range may result in a timeout reached in
// SyncBlockchainBenchmark. So if changing these, don't forget to align also the test timeout in
// waitForBalanceScreen() appropriately.
@Suppress("MagicNumber")
private val BLOCK_HEIGHT_LOWER_BOUND = BlockHeight.new(ZcashNetwork.Mainnet, 1730001L)
@Suppress("MagicNumber")
private val BLOCK_HEIGHT_UPPER_BOUND = BlockHeight.new(ZcashNetwork.Mainnet, 1730100L)
fun new(
lowerBound: BlockHeight = BLOCK_HEIGHT_LOWER_BOUND,
upperBound: BlockHeight = BLOCK_HEIGHT_UPPER_BOUND
): ClosedRange<BlockHeight> {
return lowerBound..upperBound
}
}

View File

@ -1,52 +1,52 @@
package cash.z.ecc.android.sdk.internal.block
import cash.z.ecc.android.sdk.exception.LightWalletException
import cash.z.ecc.android.sdk.internal.ext.retryUpTo
import cash.z.ecc.android.sdk.internal.ext.tryWarn
import cash.z.ecc.android.sdk.internal.model.from
import cash.z.ecc.android.sdk.internal.repository.CompactBlockRepository
import cash.z.ecc.android.sdk.internal.service.LightWalletService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.wallet.sdk.rpc.Service
import io.grpc.StatusRuntimeException
import kotlinx.coroutines.CoroutineScope
import co.electriccoin.lightwallet.client.BlockingLightWalletClient
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
import co.electriccoin.lightwallet.client.model.LightWalletEndpointInfoUnsafe
import co.electriccoin.lightwallet.client.model.Response
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Serves as a source of compact blocks received from the light wallet server. Once started, it will use the given
* lightwallet service to request all the appropriate blocks and compact block store to persist them. By delegating to
* lightWallet client to request all the appropriate blocks and compact block store to persist them. By delegating to
* 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 compactBlockRepository responsible for persisting the compact blocks that are received
* @property lightWalletClient the client used for requesting compact blocks
* @property compactBlockStore responsible for persisting the compact blocks that are received
*/
open class CompactBlockDownloader private constructor(val compactBlockRepository: CompactBlockRepository) {
lateinit var lightWalletService: LightWalletService
lateinit var lightWalletClient: BlockingLightWalletClient
private set
constructor(
lightWalletService: LightWalletService,
lightWalletClient: BlockingLightWalletClient,
compactBlockRepository: CompactBlockRepository
) : this(compactBlockRepository) {
this.lightWalletService = lightWalletService
this.lightWalletClient = lightWalletClient
}
/**
* Requests the given range of blocks from the lightwalletService and then persists them to the
* Requests the given range of blocks from the lightWalletClient and then persists them to the
* compactBlockStore.
*
* @param heightRange the inclusive range of heights to request. For example 10..20 would
* request 11 blocks (including block 10 and block 20).
*
* @return the number of blocks that were returned in the results from the lightwalletService.
* @return the number of blocks that were returned in the results from the lightWalletClient.
*/
suspend fun downloadBlockRange(heightRange: ClosedRange<BlockHeight>): Int = withContext(IO) {
val result = lightWalletService.getBlockRange(heightRange)
val result = lightWalletClient.getBlockRange(
BlockHeightUnsafe.from(heightRange.start)..BlockHeightUnsafe.from(heightRange.endInclusive)
)
compactBlockRepository.write(result)
}
@ -62,12 +62,12 @@ open class CompactBlockDownloader private constructor(val compactBlockRepository
compactBlockRepository.rewindTo(height)
/**
* Return the latest block height known by the lightwalletService.
* Return the latest block height known by the lightWalletClient.
*
* @return the latest block height.
*/
suspend fun getLatestBlockHeight() =
lightWalletService.getLatestBlockHeight()
lightWalletClient.getLatestBlockHeight()
/**
* Return the latest block height that has been persisted into the [CompactBlockRepository].
@ -77,47 +77,18 @@ open class CompactBlockDownloader private constructor(val compactBlockRepository
suspend fun getLastDownloadedHeight() =
compactBlockRepository.getLatestHeight()
suspend fun getServerInfo(): Service.LightdInfo = withContext<Service.LightdInfo>(IO) {
lateinit var result: Service.LightdInfo
try {
result = lightWalletService.getServerInfo()
} catch (e: StatusRuntimeException) {
retryUpTo(GET_SERVER_INFO_RETRIES) {
twig("WARNING: reconnecting to service in response to failure (retry #${it + 1}): $e")
lightWalletService.reconnect()
result = lightWalletService.getServerInfo()
suspend fun getServerInfo(): LightWalletEndpointInfoUnsafe? = withContext(IO) {
retryUpTo(GET_SERVER_INFO_RETRIES) {
when (val response = lightWalletClient.getServerInfo()) {
is Response.Success -> return@withContext response.result
else -> {
lightWalletClient.reconnect()
twig("WARNING: reconnecting to server in response to failure (retry #${it + 1})")
}
}
}
result
}
suspend fun changeService(
newService: LightWalletService,
errorHandler: (Throwable) -> Unit = { throw it }
) = withContext(IO) {
@Suppress("TooGenericExceptionCaught")
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)
}
null
}
/**
@ -125,7 +96,7 @@ open class CompactBlockDownloader private constructor(val compactBlockRepository
*/
suspend fun stop() {
withContext(Dispatchers.IO) {
lightWalletService.shutdown()
lightWalletClient.shutdown()
}
compactBlockRepository.close()
}
@ -135,35 +106,7 @@ open class CompactBlockDownloader private constructor(val compactBlockRepository
*
* @return the full transaction info.
*/
fun fetchTransaction(txId: ByteArray) = lightWalletService.fetchTransaction(txId)
//
// Convenience functions
//
private suspend fun CoroutineScope.gracefullyShutdown(service: LightWalletService) = launch {
@Suppress("MagicNumber")
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<String>().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")
}
}
fun fetchTransaction(txId: ByteArray) = lightWalletClient.fetchTransaction(txId)
companion object {
private const val GET_SERVER_INFO_RETRIES = 6

View File

@ -8,7 +8,7 @@ import cash.z.ecc.android.sdk.internal.db.commonDatabaseBuilder
import cash.z.ecc.android.sdk.internal.repository.CompactBlockRepository
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.wallet.sdk.rpc.CompactFormats
import cash.z.wallet.sdk.internal.rpc.CompactFormats
import kotlinx.coroutines.withContext
import java.io.File

View File

@ -42,32 +42,6 @@ suspend inline fun retryUpTo(
}
}
/**
* Execute the given block and if it fails, retry up to [retries] more times, using thread sleep
* instead of suspending. If none of the retries succeed then throw the final error. This function
* is intended to be called with no parameters, i.e., it is designed to use its defaults.
*
* @param retries the number of times to retry. Typically, this should be low.
* @param sleepTime the amount of time to sleep in between retries. Typically, this should be an
* amount of time that is hard to perceive.
* @param block the block of logic to try.
*/
inline fun retrySimple(retries: Int = 2, sleepTime: Long = 20L, block: (Int) -> Unit) {
var failedAttempts = 0
while (failedAttempts <= retries) {
@Suppress("TooGenericExceptionCaught")
try {
block(failedAttempts)
return
} catch (t: Throwable) {
failedAttempts++
if (failedAttempts > retries) throw t
twig("failed due to $t simply retrying ($failedAttempts/$retries) in ${sleepTime}ms...")
Thread.sleep(sleepTime)
}
}
}
/**
* Execute the given block and if it fails, retry with an exponential backoff.
*

View File

@ -0,0 +1,11 @@
package cash.z.ecc.android.sdk.internal.model
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
internal fun BlockHeightUnsafe.Companion.from(blockHeight: BlockHeight) =
BlockHeightUnsafe(blockHeight.value)
internal fun BlockHeightUnsafe.toBlockHeight(zcashNetwork: ZcashNetwork) =
BlockHeight.new(zcashNetwork, value)

View File

@ -1,7 +1,7 @@
package cash.z.ecc.android.sdk.internal.repository
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.wallet.sdk.rpc.CompactFormats
import cash.z.wallet.sdk.internal.rpc.CompactFormats
/**
* Interface for storing compact blocks.

View File

@ -1,210 +0,0 @@
package cash.z.ecc.android.sdk.internal.service
import android.content.Context
import cash.z.ecc.android.sdk.annotation.OpenForTesting
import cash.z.ecc.android.sdk.ext.BenchmarkingExt
import cash.z.ecc.android.sdk.fixture.BlockRangeFixture
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.wallet.sdk.rpc.CompactFormats
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc
import cash.z.wallet.sdk.rpc.Service
import com.google.protobuf.ByteString
import io.grpc.Channel
import io.grpc.ConnectivityState
import io.grpc.ManagedChannel
import io.grpc.android.AndroidChannelBuilder
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Implementation of LightwalletService using gRPC for requests to lightwalletd.
*
* @property channel the channel to use for communicating with the lightwalletd server.
* @property singleRequestTimeout 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 streamingRequestTimeout 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(
context: Context,
private val lightWalletEndpoint: LightWalletEndpoint,
var channel: ManagedChannel,
private val singleRequestTimeout: Duration = 10.seconds,
private val streamingRequestTimeout: Duration = 90.seconds
) : LightWalletService {
private val applicationContext = context.applicationContext
/* LightWalletService implementation */
override fun getBlockRange(heightRange: ClosedRange<BlockHeight>): Sequence<CompactFormats.CompactBlock> {
if (heightRange.isEmpty()) {
return emptySequence()
}
return requireChannel().createStub(streamingRequestTimeout)
.getBlockRange(heightRange.toBlockRange()).iterator().asSequence()
}
override fun getLatestBlockHeight(): BlockHeight {
return if (BenchmarkingExt.isBenchmarking()) {
// We inject a benchmark test blocks range at this point to process only a restricted range of blocks
// for a more reliable benchmark results.
BlockRangeFixture.new().endInclusive
} else {
BlockHeight(
requireChannel().createStub(singleRequestTimeout)
.getLatestBlock(Service.ChainSpec.newBuilder().build()).height
)
}
}
override fun getServerInfo(): Service.LightdInfo {
return requireChannel().createStub(singleRequestTimeout)
.getLightdInfo(Service.Empty.newBuilder().build())
}
override fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse {
if (spendTransaction.isEmpty()) {
return Service.SendResponse.newBuilder()
.setErrorCode(EMPTY_TRANSACTION_ERROR_CODE)
.setErrorMessage(EMPTY_TRANSACTION_ERROR_MESSAGE)
.build()
}
val request =
Service.RawTransaction.newBuilder().setData(ByteString.copyFrom(spendTransaction))
.build()
return requireChannel().createStub().sendTransaction(request)
}
override fun shutdown() {
twig("Shutting down channel")
channel.shutdown()
}
override fun fetchTransaction(txId: ByteArray): Service.RawTransaction? {
if (txId.isEmpty()) return null
return requireChannel().createStub().getTransaction(
Service.TxFilter.newBuilder().setHash(ByteString.copyFrom(txId)).build()
)
}
override fun fetchUtxos(
tAddress: String,
startHeight: BlockHeight
): List<Service.GetAddressUtxosReply> {
val result = requireChannel().createStub().getAddressUtxos(
Service.GetAddressUtxosArg.newBuilder().setAddress(tAddress)
.setStartHeight(startHeight.value).build()
)
return result.addressUtxosList
}
override fun getTAddressTransactions(
tAddress: String,
blockHeightRange: ClosedRange<BlockHeight>
): List<Service.RawTransaction> {
if (blockHeightRange.isEmpty() || tAddress.isBlank()) return listOf()
val result = requireChannel().createStub().getTaddressTxids(
Service.TransparentAddressBlockFilter.newBuilder().setAddress(tAddress)
.setRange(blockHeightRange.toBlockRange()).build()
)
return result.toList()
}
override fun reconnect() {
twig("closing existing channel and then reconnecting")
channel.shutdown()
channel = createDefaultChannel(applicationContext, lightWalletEndpoint)
}
// test code
internal var stateCount = 0
internal var state: ConnectivityState? = null
private fun requireChannel(): ManagedChannel {
state = channel.getState(false).let { new ->
if (state == new) stateCount++ else stateCount = 0
new
}
channel.resetConnectBackoff()
twig(
"getting channel isShutdown: ${channel.isShutdown} " +
"isTerminated: ${channel.isTerminated} " +
"getState: $state stateCount: $stateCount",
-1
)
return channel
}
companion object {
private const val EMPTY_TRANSACTION_ERROR_CODE = 3000
private const val EMPTY_TRANSACTION_ERROR_MESSAGE = "ERROR: failed to submit transaction because it was" +
" empty so this request was ignored on the client-side."
fun new(context: Context, lightWalletEndpoint: LightWalletEndpoint): LightWalletGrpcService {
val channel = createDefaultChannel(context, lightWalletEndpoint)
return LightWalletGrpcService(context, lightWalletEndpoint, channel)
}
/**
* Convenience function for creating the default channel to be used for all connections. It
* is important that this channel can handle transitioning from WiFi to Cellular connections
* and is properly setup to support TLS, when required.
*/
fun createDefaultChannel(
appContext: Context,
lightWalletEndpoint: LightWalletEndpoint
): ManagedChannel {
twig(
"Creating channel that will connect to " +
"${lightWalletEndpoint.host}:${lightWalletEndpoint.port}" +
"/?usePlaintext=${!lightWalletEndpoint.isSecure}"
)
return AndroidChannelBuilder
.forAddress(lightWalletEndpoint.host, lightWalletEndpoint.port)
.context(appContext)
.enableFullStreamDecompression()
.apply {
if (lightWalletEndpoint.isSecure) {
useTransportSecurity()
} else {
twig("WARNING: Using insecure channel")
usePlaintext()
}
}
.build()
}
}
}
private fun Channel.createStub(timeoutSec: Duration = 60.seconds) = CompactTxStreamerGrpc
.newBlockingStub(this)
.withDeadlineAfter(timeoutSec.inWholeSeconds, TimeUnit.SECONDS)
private fun BlockHeight.toBlockHeight(): Service.BlockID =
Service.BlockID.newBuilder().setHeight(value).build()
private fun ClosedRange<BlockHeight>.toBlockRange(): Service.BlockRange =
Service.BlockRange.newBuilder()
.setStart(start.toBlockHeight())
.setEnd(endInclusive.toBlockHeight())
.build()
/**
* This function effectively parses streaming responses. Each call to next(), on the iterators
* returned from grpc, triggers a network call.
*/
private fun <T> Iterator<T>.toList(): List<T> =
mutableListOf<T>().apply {
while (hasNext()) {
this@apply += next()
}
}

View File

@ -1,96 +0,0 @@
package cash.z.ecc.android.sdk.internal.service
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.wallet.sdk.rpc.CompactFormats
import cash.z.wallet.sdk.rpc.Service
/**
* Service for interacting with lightwalletd. Implementers of this service should make blocking
* 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?
/**
* Fetch all UTXOs for the given address, going back to the start height.
*
* @param tAddress the transparent address to use.
* @param startHeight the starting height to use.
*
* @return the UTXOs for the given address from the startHeight.
*/
fun fetchUtxos(tAddress: String, startHeight: BlockHeight): List<Service.GetAddressUtxosReply>
/**
* Return the given range of blocks.
*
* @param heightRange the inclusive range to fetch. For instance if 1..5 is given, then every
* block in that range will be fetched, including 1 and 5.
*
* @return a list of compact blocks for the given range
*
*/
fun getBlockRange(heightRange: ClosedRange<BlockHeight>): Sequence<CompactFormats.CompactBlock>
/**
* Return the latest block height known to the service.
*
* @return the latest block height known to the service.
*/
fun getLatestBlockHeight(): BlockHeight
/**
* Return basic information about the server such as:
*
* ```
* {
* "version": "0.2.1",
* "vendor": "ECC LightWalletD",
* "taddrSupport": true,
* "chainName": "main",
* "saplingActivationHeight": 419200,
* "consensusBranchId": "2bb40e60",
* "blockHeight": 861272
* }
* ```
*
* @return useful server details.
*/
fun getServerInfo(): Service.LightdInfo
/**
* 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
* indexed and responses are fairly quick.
*
* @return a list of transactions that correspond to the given address for the given range.
*/
fun getTAddressTransactions(
tAddress: String,
blockHeightRange: ClosedRange<BlockHeight>
): List<Service.RawTransaction>
/**
* 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 reconnect()
/**
* Cleanup any connections when the service is shutting down and not going to be used again.
*/
fun shutdown()
/**
* Submit a raw transaction.
*
* @return the response from the server.
*/
fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse
}

View File

@ -11,7 +11,6 @@ import cash.z.ecc.android.sdk.internal.db.pending.isCancelled
import cash.z.ecc.android.sdk.internal.db.pending.isFailedEncoding
import cash.z.ecc.android.sdk.internal.db.pending.isSubmitted
import cash.z.ecc.android.sdk.internal.db.pending.recipient
import cash.z.ecc.android.sdk.internal.service.LightWalletService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
@ -20,6 +19,8 @@ import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.lightwallet.client.BlockingLightWalletClient
import co.electriccoin.lightwallet.client.model.Response
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
@ -46,7 +47,7 @@ internal class PersistentTransactionManager(
db: PendingTransactionDb,
private val zcashNetwork: ZcashNetwork,
internal val encoder: TransactionEncoder,
private val service: LightWalletService
private val service: BlockingLightWalletClient
) : OutboundTransactionManager {
private val daoMutex = Mutex()
@ -214,16 +215,24 @@ internal class PersistentTransactionManager(
)
else -> {
twig("submitting transaction with memo: ${tx.memo} amount: ${tx.value}", -1)
val response = service.submitTransaction(tx.raw)
val error = response.errorCode < 0
twig(
"${if (error) "FAILURE! " else "SUCCESS!"} submit transaction completed with" +
" response: ${response.errorCode}: ${response.errorMessage}"
)
safeUpdate("updating submitted transaction (hadError: $error)", -1) {
updateError(tx.id, if (error) response.errorMessage else null, response.errorCode)
updateSubmitAttempts(tx.id, max(1, tx.submitAttempts + 1))
when (val response = service.submitTransaction(tx.raw)) {
is Response.Success -> {
twig("SUCCESS: submit transaction completed with response: ${response.result}")
safeUpdate("updating submitted transaction (hadError: false)", -1) {
updateError(tx.id, null, response.result.code)
updateSubmitAttempts(tx.id, max(1, tx.submitAttempts + 1))
}
}
is Response.Failure -> {
twig(
"FAILURE! submit transaction completed with response: ${response.code}: ${response
.description}"
)
safeUpdate("updating submitted transaction (hadError: true)", -1) {
updateError(tx.id, response.description, response.code)
updateSubmitAttempts(tx.id, max(1, tx.submitAttempts + 1))
}
}
}
}
}
@ -355,7 +364,7 @@ internal class PersistentTransactionManager(
appContext: Context,
zcashNetwork: ZcashNetwork,
encoder: TransactionEncoder,
service: LightWalletService,
service: BlockingLightWalletClient,
databaseFile: File
) = PersistentTransactionManager(
commonDatabaseBuilder(

View File

@ -2,6 +2,8 @@
package cash.z.ecc.android.sdk.model
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
/*
* This is a set of extension functions currently, because we expect them to change in the future.
*/

View File

@ -1,48 +0,0 @@
syntax = "proto3";
package cash.z.wallet.sdk.rpc;
option go_package = "walletrpc";
option swift_prefix = "";
// Remember that proto3 fields are all optional. A field that is not present will be set to its zero value.
// bytes fields of hashes are in canonical little-endian format.
// CompactBlock is a packaging of ONLY the data from a block that's needed to:
// 1. Detect a payment to your shielded Sapling address
// 2. Detect a spend of your shielded Sapling notes
// 3. Update your witnesses to generate new Sapling spend proofs.
message CompactBlock {
uint32 protoVersion = 1; // the version of this wire format, for storage
uint64 height = 2; // the height of this block
bytes hash = 3;
bytes prevHash = 4;
uint32 time = 5;
bytes header = 6; // (hash, prevHash, and time) OR (full header)
repeated CompactTx vtx = 7; // compact transactions from this block
}
message CompactTx {
// Index and hash will allow the receiver to call out to chain
// explorers or other data structures to retrieve more information
// about this transaction.
uint64 index = 1;
bytes hash = 2;
// The transaction fee: present if server can provide. In the case of a
// stateless server and a transaction with transparent inputs, this will be
// unset because the calculation requires reference to prior transactions.
// in a pure-Sapling context, the fee will be calculable as:
// valueBalance + (sum(vPubNew) - sum(vPubOld) - sum(tOut))
uint32 fee = 3;
repeated CompactSpend spends = 4;
repeated CompactOutput outputs = 5;
}
message CompactSpend {
bytes nf = 1;
}
message CompactOutput {
bytes cmu = 1;
bytes epk = 2;
bytes ciphertext = 3;
}

View File

@ -1,5 +1,6 @@
package cash.z.ecc.android.sdk.model
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import org.junit.Test
import kotlin.test.assertTrue

View File

@ -96,7 +96,8 @@ dependencyResolutionManagement {
val coroutinesOkhttpVersion = extra["COROUTINES_OKHTTP"].toString()
val flankVersion = extra["FLANK_VERSION"].toString()
val googleMaterialVersion = extra["GOOGLE_MATERIAL_VERSION"].toString()
val grpcVersion = extra["GRPC_VERSION"].toString()
val grpcJavaVersion = extra["GRPC_VERSION"].toString()
val grpcKotlinVersion = extra["GRPC_KOTLIN_VERSION"].toString()
val gsonVersion = extra["GSON_VERSION"].toString()
val guavaVersion = extra["GUAVA_VERSION"].toString()
val javaVersion = extra["ANDROID_JVM_TARGET"].toString()
@ -113,7 +114,7 @@ dependencyResolutionManagement {
// Standalone versions
version("flank", flankVersion)
version("grpc", grpcVersion)
version("grpc", grpcJavaVersion)
version("java", javaVersion)
version("kotlin", kotlinVersion)
version("protoc", protocVersion)
@ -125,8 +126,9 @@ dependencyResolutionManagement {
library("gradle-plugin-rust", "org.mozilla.rust-android-gradle:plugin:$rustGradlePluginVersion")
// Special cases used by the grpc gradle plugin
library("grpc-protoc", "io.grpc:protoc-gen-grpc-java:$grpcVersion")
library("protoc", "com.google.protobuf:protoc:$protocVersion")
library("protoc-compiler", "com.google.protobuf:protoc:$protocVersion")
library("protoc-gen-java", "io.grpc:protoc-gen-grpc-java:$grpcJavaVersion")
library("protoc-gen-kotlin", "io.grpc:protoc-gen-grpc-kotlin:$grpcKotlinVersion")
// Libraries
library("androidx-annotation", "androidx.annotation:annotation:$androidxAnnotationVersion")
@ -149,10 +151,12 @@ dependencyResolutionManagement {
library("androidx-sqlite-framework", "androidx.sqlite:sqlite-framework:${androidxDatabaseVersion}")
library("androidx-viewmodel-compose", "androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
library("bip39", "cash.z.ecc.android:kotlin-bip39:$bip39Version")
library("grpc-android", "io.grpc:grpc-android:$grpcVersion")
library("grpc-okhttp", "io.grpc:grpc-okhttp:$grpcVersion")
library("grpc-protobuf", "io.grpc:grpc-protobuf-lite:$grpcVersion")
library("grpc-stub", "io.grpc:grpc-stub:$grpcVersion")
library("grpc-android", "io.grpc:grpc-android:$grpcJavaVersion")
library("grpc-kotlin", "com.google.protobuf:protobuf-kotlin-lite:$protocVersion")
library("grpc-kotlin-stub", "io.grpc:grpc-kotlin-stub:$grpcKotlinVersion")
library("grpc-okhttp", "io.grpc:grpc-okhttp:$grpcJavaVersion")
library("grpc-protobuf", "io.grpc:grpc-protobuf-lite:$grpcJavaVersion")
library("grpc-stub", "io.grpc:grpc-stub:$grpcJavaVersion")
library("gson", "com.google.code.gson:gson:$gsonVersion")
library("guava", "com.google.guava:guava:$guavaVersion")
library("javax-annotation", "javax.annotation:javax.annotation-api:$javaxAnnotationVersion")
@ -189,7 +193,7 @@ dependencyResolutionManagement {
library("androidx-tracing", "androidx.tracing:tracing:$androidxTracingVersion")
library("androidx-uiAutomator", "androidx.test.uiautomator:uiautomator:$androidxUiAutomatorVersion")
library("coroutines-okhttp", "ru.gildor.coroutines:kotlin-coroutines-okhttp:$coroutinesOkhttpVersion")
library("grpc-testing", "io.grpc:grpc-testing:$grpcVersion")
library("grpc-testing", "io.grpc:grpc-testing:$grpcJavaVersion")
library("junit-api", "org.junit.jupiter:junit-jupiter-api:$junitVersion")
library("junit-engine", "org.junit.jupiter:junit-jupiter-engine:$junitVersion")
library("junit-migration", "org.junit.jupiter:junit-jupiter-migrationsupport:$junitVersion")
@ -203,8 +207,10 @@ dependencyResolutionManagement {
bundle(
"grpc",
listOf(
"grpc-okhttp",
"grpc-android",
"grpc-kotlin",
"grpc-kotlin-stub",
"grpc-okhttp",
"grpc-protobuf",
"grpc-stub"
)
@ -260,6 +266,7 @@ rootProject.name = "zcash-android-sdk"
includeBuild("build-conventions")
include("darkside-test-lib")
include("sdk-lib")
include("demo-app")
include("demo-app-benchmark-test")
include("demo-app-benchmark-test")
include("lightwallet-client-lib")
include("sdk-lib")