Cleanup Synchronizer API, add KDocs and remove dead code

This commit is contained in:
Kevin Gorham 2019-03-29 02:04:25 -04:00 committed by Kevin Gorham
parent 796fe9602c
commit 72283cee81
4 changed files with 230 additions and 87 deletions

View File

@ -32,7 +32,7 @@ In the spirit of transparency, we provide this as a window into what we are acti
### 🛑 Use of this code may lead to a loss of funds 🛑
Use of this code in its current form or with modifications may lead to loss of funds, loss of "expected" privacy, or denial of service for a large portion of users, or a bug which could leverage any of those kinds of attacks (especially a "0 day" where we suspect few people know about the vulnerability).
Use of this code in its current form or with modifications may lead to loss of funds, loss of "expected" privacy, or denial of service for a large portion of users, or a bug which could leverage any of those kinds of attacks (especially a "0 day" where we suspect few people know about the vulnerability).
### :eyes: At this time, this is for preview purposes only. :eyes:
@ -42,18 +42,15 @@ In the spirit of transparency, we provide this as a window into what we are acti
This lightweight SDK connects Android to Zcash. It welds together Rust and Kotlin in a minimal way, allowing third-party Android apps to send and receive shielded transactions easily, securely and privately.
# Usage
## Contents
:warning: Presently, the latest stable code lives in the `preview` branch, under active development, and is not yet released.
- [Structure](#structure)
- [Overview](#overview)
- [Components](#components)
- [Quickstart](#quickstart)
- [Compiling Sources](#compiling-sources)
Compilation requires `Cargo` and has been tested on Ubuntu, MacOS and Windows. To compile the SDK run:
```bash
./gradlew assembleZcashtestnetRelease
```
This creates a `testnet` build of the SDK that can be used to preview basic functionality for sending and receiving shielded transactions. If you do not have `Rust` and `Cargo` installed, the build script will let you know and provide further instructions for installation.
# Structure
## Structure
From an app developer's perspective, this SDK will encapsulate the most complex aspects of using Zcash, freeing the developer to focus on UI and UX, rather than scanning blockchains and building commitment trees! Internally, the SDK is structured as follows:
@ -62,14 +59,19 @@ From an app developer's perspective, this SDK will encapsulate the most complex
Thankfully, the only thing an app developer has to be concerned with is the following:
![SDK Diagram Developer Perspective](assets/sdk_dev_pov_final.png?raw=true "SDK Diagram Dev PoV")
![SDK Diagram Developer Perspective](assets/sdk_dev_pov_final.png?raw=true "SDK Diagram Dev PoV")
The primary steps for a 3rd party developer to make use of this SDK are simply:
[Back to contents](#contents)
## Overview
1. Start the synchronizer
2. Consume wallet data via channels
At a high level, this SDK simply helps native Android codebases connect to Zcash's Rust crypto libraries without needing to know Rust or be a Cryptographer. Think of it as welding. The SDK takes separate things and tightly bonds them together such that each can remain as idiomatic as possible. It's goal is to make it easy for an app to incorporate shielded transactions while remaining a good citizen on mobile devices.
Given all the moving parts, making things easy requires coordination. The [Synchronizer](docs/synchronizer/index.md) provides that layer of abstraction so that the primary steps to make use of this SDK are simply:
1. Start the [Synchronizer](docs/synchronizer/index.md)
2. Subscribe to wallet data
The Sychronizer takes care of
The [Synchronizer](docs/synchronizer/index.md) takes care of
- Connecting to the light wallet server
- Downloading the latest compact blocks in a privacy-sensitive way
@ -78,10 +80,51 @@ The Sychronizer takes care of
- Sending payments to a full node through the light wallet server
- Monitoring sent payments for status updates
At a high level, the Synchronizer provides ReceiveChannels that broadcast transaction and balance information. This allows the wallet to simply subscribe to those channels and stay updated with the latest shielded transaction information.
At a more granular level...
To accomplish this, these responsibilities of the SDK are divided into separate components. Each component is coordinated by the [Synchronizer](docs/synchronizer/index.md), which is the thread that ties it all together.
Visit the [preview branch](https://github.com/zcash/zcash-android-wallet-sdk/tree/preview) for more detailed documentation and the latest code.
#### Components
:warning: Presently, the latest stable code lives in the `preview` branch, under active development, and is not yet released.
| Component | Summary | Input | Output |
| :--------- | :------------ | :--- | :--- |
| **Downloader** | Downloads compact blocks | Server host:port | Stream of compact blocks |
| **Processor** | Processes compact blocks | Stream of compact blocks | Decoded wallet data |
| **Repository** | Source of data derived from processing blocks | Decoded wallet data | UI Data |
| **Active Transaction Manager** | Manages the lifecycle of pending transactions | Decoded wallet data | Transaction state |
| **Wallet** | Wraps the Zcash rust libraries, insulating SDK users from changes in that layer | Configuration | Configuration |
[Back to contents](#contents)
## Quickstart
Add the SDK dependency
```gradle
implementation "cash.z.android.wallet:zcash-android-testnet:1.7.5-alpha@aar"
```
Start the [Synchronizer](docs/synchronizer/index.md)
```kotlin
synchronizer.start(this)
```
Get the wallet's address
```kotlin
synchronizer.getAddress()
```
Send funds to another address
```kotlin
synchronizer.sendToAddress(zatoshi, address, memo)
```
[Back to contents](#contents)
## Compiling Sources
:warning: Presently, the latest stable code lives in the `preview` branch, under active development, and is not yet released.
Importing the dependency should be enough for use but in the event that you want to compile the SDK from sources, including the Kotlin and Rust portions, simply use Gradle.
Compilation requires `Cargo` and has been tested on Ubuntu, MacOS and Windows. To compile the SDK run:
```bash
./gradlew clean assembleZcashtestnetRelease
```
This creates a `testnet` build of the SDK that can be used to preview basic functionality for sending and receiving shielded transactions. If you do not have `Rust` and `Cargo` installed, the build script will let you know and provide further instructions for installation. Note that merely using the SDK does not require installing Rust or Cargo--that is only required for compilation.
[Back to contents](#contents)

View File

@ -71,9 +71,9 @@ open class MockSynchronizer(
override fun balance() = balanceChannel.openSubscription()
override fun progress() = progressChannel.openSubscription()
override suspend fun isOutOfSync(): Boolean {
override suspend fun isStale(): Boolean {
val result = isOutOfSync ?: (Random.nextInt(100) < 10)
twig("checking isOutOfSync: $result")
twig("checking isStale: $result")
if(isOutOfSync == true) launch { delay(20_000L); isOutOfSync = false }
return result
}
@ -83,63 +83,67 @@ open class MockSynchronizer(
return isFirstRun
}
override val address get() = mockAddress.also { twig("returning mock address $mockAddress") }
override fun getAddress(accountId: Int): String = mockAddress.also { twig("returning mock address $mockAddress") }
override suspend fun sendToAddress(zatoshi: Long, toAddress: String) = withContext<Unit>(Dispatchers.IO) {
Twig.sprout("send")
val walletTransaction = forge.createSendTransaction(zatoshi)
val activeTransaction = forge.createActiveSendTransaction(walletTransaction, toAddress)
val isInvalidForTestnet = toAddress.length != 88 && toAddress.startsWith("ztest")
val isInvalidForMainnet = toAddress.length != 78 && toAddress.startsWith("zs")
val state = when {
zatoshi < 0 -> TransactionState.Failure(TransactionState.Creating, "amount cannot be negative")
!toAddress.startsWith("z") -> TransactionState.Failure(TransactionState.Creating, "address must start with z")
isInvalidForTestnet -> TransactionState.Failure(TransactionState.Creating, "invalid testnet address")
isInvalidForMainnet -> TransactionState.Failure(TransactionState.Creating, "invalid mainnet address")
else -> TransactionState.Creating
}
twig("after input validation, state is being set to ${state::class.simpleName}")
setState(activeTransaction, state)
twig("active tx size is ${activeTransactions.size}")
// next, transition it through the states, if it got created
if (state !is TransactionState.Creating) {
twig("failed to create transaction")
return@withContext
} else {
// first, add the transaction
twig("adding transaction")
transactionMutex.withLock {
transactions.add(walletTransaction)
override suspend fun sendToAddress(zatoshi: Long, toAddress: String, memo: String, fromAccountId: Int) =
withContext<Unit>(Dispatchers.IO) {
Twig.sprout("send")
val walletTransaction = forge.createSendTransaction(zatoshi)
val activeTransaction = forge.createActiveSendTransaction(walletTransaction, toAddress)
val isInvalidForTestnet = toAddress.length != 88 && toAddress.startsWith("ztest")
val isInvalidForMainnet = toAddress.length != 78 && toAddress.startsWith("zs")
val state = when {
zatoshi < 0 -> TransactionState.Failure(TransactionState.Creating, "amount cannot be negative")
!toAddress.startsWith("z") -> TransactionState.Failure(
TransactionState.Creating,
"address must start with z"
)
isInvalidForTestnet -> TransactionState.Failure(TransactionState.Creating, "invalid testnet address")
isInvalidForMainnet -> TransactionState.Failure(TransactionState.Creating, "invalid mainnet address")
else -> TransactionState.Creating
}
twig("after input validation, state is being set to ${state::class.simpleName}")
setState(activeTransaction, state)
// then update the active transaction through the creation and submission steps
listOf(TransactionState.Created(walletTransaction.txId), TransactionState.SendingToNetwork)
.forEach { newState ->
if (!job.isActive) return@withContext
delay(activeTransactionUpdateFrequency)
setState(activeTransaction, newState)
twig("active tx size is ${activeTransactions.size}")
// next, transition it through the states, if it got created
if (state !is TransactionState.Creating) {
twig("failed to create transaction")
return@withContext
} else {
// first, add the transaction
twig("adding transaction")
transactionMutex.withLock {
transactions.add(walletTransaction)
}
// then set the wallet transaction's height (to simulate it being mined)
val minedHeight = forge.latestHeight.getAndIncrement()
transactionMutex.withLock {
transactions.remove(walletTransaction)
transactions.add(walletTransaction.copy(height = minedHeight, isMined = true))
}
// then update the active transaction through the creation and submission steps
listOf(TransactionState.Created(walletTransaction.txId), TransactionState.SendingToNetwork)
.forEach { newState ->
if (!job.isActive) return@withContext
delay(activeTransactionUpdateFrequency)
setState(activeTransaction, newState)
}
// simply transition it through the states
List(11) { TransactionState.AwaitingConfirmations(it) }
.forEach { newState ->
if (!job.isActive) return@withContext
delay(activeTransactionUpdateFrequency)
activeTransaction.height.set(minedHeight + newState.confirmationCount)
setState(activeTransaction, newState)
// then set the wallet transaction's height (to simulate it being mined)
val minedHeight = forge.latestHeight.getAndIncrement()
transactionMutex.withLock {
transactions.remove(walletTransaction)
transactions.add(walletTransaction.copy(height = minedHeight, isMined = true))
}
// simply transition it through the states
List(11) { TransactionState.AwaitingConfirmations(it) }
.forEach { newState ->
if (!job.isActive) return@withContext
delay(activeTransactionUpdateFrequency)
activeTransaction.height.set(minedHeight + newState.confirmationCount)
setState(activeTransaction, newState)
}
}
Twig.clip("send")
}
Twig.clip("send")
}
private suspend fun setState(activeTransaction: ActiveTransaction, state: TransactionState) {
var copyMap = mutableMapOf<ActiveTransaction, TransactionState>()

View File

@ -29,15 +29,34 @@ class SdkSynchronizer(
private val blockPollFrequency: Long = CompactBlockStream.DEFAULT_POLL_INTERVAL
) : Synchronizer {
/**
* The primary job for this Synchronizer. It leverages structured concurrency to cancel all work when the
* `parentScope` provided to the [start] method ends.
*/
private lateinit var blockJob: Job
/**
* The state this Synchronizer was in when it started. This is helpful because the conditions that lead to FirstRun
* or isStale being detected can change quickly so retaining the initial state is useful for walkthroughs or other
* elements of an app that rely on this information later.
*/
private lateinit var initialState: SyncState
//
// Public API
//
/* Lifecycle */
/**
* Starts this synchronizer within the given scope. For simplicity, attempting to start an instance that has already
* been started will throw a [SynchronizerException.FalseStart] exception. This reduces the complexity of managing
* resources that must be recycled. Instead, each synchronizer is designed to have a long lifespan (similar to act or application) <=- explain usage
*
* @param parentScope the scope to use for this synchronizer, typically something with a lifecycle such as an
* Activity. Implementations should leverage structured concurrency and cancel all jobs when this scope completes.
*/
override fun start(parentScope: CoroutineScope): Synchronizer {
val supervisorJob = SupervisorJob(parentScope.coroutineContext[Job])
// prevent restarts so the behavior of this class is easier to reason about
@ -80,7 +99,7 @@ class SdkSynchronizer(
/* Status */
override suspend fun isOutOfSync(): Boolean = withContext(IO) {
override suspend fun isStale(): Boolean = withContext(IO) {
val latestBlockHeight = downloader.connection.getLatestBlockHeight()
val ourHeight = processor.cacheDao.latestBlockHeight()
val tolerance = 10
@ -95,10 +114,10 @@ class SdkSynchronizer(
/* Operations */
override val address get() = wallet.getAddress()
override fun getAddress(accountId: Int): String = wallet.getAddress()
override suspend fun sendToAddress(zatoshi: Long, toAddress: String) =
activeTransactionManager.sendToAddress(zatoshi, toAddress)
override suspend fun sendToAddress(zatoshi: Long, toAddress: String, memo: String, fromAccountId: Int) =
activeTransactionManager.sendToAddress(zatoshi, toAddress, memo, fromAccountId)
override fun cancelSend(transaction: ActiveSendTransaction): Boolean = activeTransactionManager.cancel(transaction)

View File

@ -5,33 +5,110 @@ import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.ReceiveChannel
/**
* Primary interface for interacting with the SDK. Defines the contract that specific implementations like
* [MockSynchronizer] and [SdkSynchronizer] fulfill. Given the language-level support for coroutines, we favor their use
* in the SDK and incorporate that choice into this contract.
*/
interface Synchronizer {
/* Lifecycle */
/**
* Starts this synchronizer within the given scope.
*
* @param parentScope the scope to use for this synchronizer, typically something with a lifecycle such as an
* Activity. Implementations should leverage structured concurrency and cancel all jobs when this scope completes.
*/
fun start(parentScope: CoroutineScope): Synchronizer
/**
* Stop this synchronizer.
*/
fun stop()
/* Channels */
// NOTE: each of these are expected to be a broadcast channel, such that [receive] always returns the latest value
fun activeTransactions(): ReceiveChannel<Map<ActiveTransaction, TransactionState>>
fun allTransactions(): ReceiveChannel<List<WalletTransaction>>
fun balance(): ReceiveChannel<Wallet.WalletBalance>
fun progress(): ReceiveChannel<Int>
/* Status */
suspend fun isOutOfSync(): Boolean
suspend fun isFirstRun(): Boolean
val address: String
/**
* Called whenever there is an uncaught exception.
* A stream of all the active transactions.
*/
fun activeTransactions(): ReceiveChannel<Map<ActiveTransaction, TransactionState>>
/**
* A stream of all the wallet transactions.
*/
fun allTransactions(): ReceiveChannel<List<WalletTransaction>>
/**
* A stream of balance values.
*/
fun balance(): ReceiveChannel<Wallet.WalletBalance>
/**
* A stream of progress values, typically corresponding to this Synchronizer downloading blocks. Typically, any non-
* zero value below 100 indicates that progress indicators can be shown and a value of 100 signals that progress is
* complete and any progress indicators can be hidden.
*/
fun progress(): ReceiveChannel<Int>
/* Status */
/**
* A flag to indicate that this Synchronizer is significantly out of sync with it's server. Typically, this means
* that the balance and other data cannot be completely trusted because a significant amount of data has not been
* processed. This is intended for showing progress indicators when the user returns to the app after having not
* used it for days. Typically, this means minor sync issues should be ignored and this should be leveraged in order
* to alert a user that the balance information is stale.
*
* @return true when the local data is significantly out of sync with the remote server and the app data is stale.
*/
suspend fun isStale(): Boolean
/**
* A flag to indicate that this is the first run of this Synchronizer on this device. This is useful for knowing
* whether to initialize databases or other required resources, as well as whether to show walk-throughs.
*
* @return true when this is the first run. Implementations can set criteria for that but typically it will be when
* the database needs to be initialized.
*/
suspend fun isFirstRun(): Boolean
/**
* Gets or sets a global error listener. This is a useful hook for handling unexpected critical errors.
*
* @return true when the error has been handled and the Synchronizer should continue. False when the error is
* unrecoverable and the Synchronizer should [stop].
*/
var onSynchronizerErrorListener: ((Throwable?) -> Boolean)?
/* Operations */
suspend fun sendToAddress(zatoshi: Long, toAddress: String)
/**
* Gets the address for the given account.
*
* @param accountId the optional accountId whose address of interest. By default, the first account is used.
*/
fun getAddress(accountId: Int = 0): String
/**
* Sends zatoshi.
*
* @param zatoshi the amount of zatoshi to send.
* @param toAddress the recipient's address.
* @param memo the optional memo to include as part of the transaction.
* @param fromAccountId the optional account id to use. By default, the first account is used.
*/
suspend fun sendToAddress(zatoshi: Long, toAddress: String, memo: String = "", fromAccountId: Int = 0)
/**
* Attempts to cancel a previously sent transaction. Typically, cancellation is only an option if the transaction
* has not yet been submitted to the server.
*
* @param transaction the transaction to cancel.
* @return true when the cancellation request was successful. False when it is too late to cancel.
*/
fun cancelSend(transaction: ActiveSendTransaction): Boolean
}