First pass at adding documentation.

This commit is contained in:
Kevin Gorham 2020-02-27 03:25:07 -05:00
parent 785f9d5421
commit de0d85c20d
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
20 changed files with 484 additions and 34 deletions

View File

@ -6,6 +6,10 @@ import cash.z.wallet.sdk.demoapp.App
import cash.z.wallet.sdk.demoapp.BaseDemoFragment
import cash.z.wallet.sdk.demoapp.databinding.FragmentGetAddressBinding
/**
* Displays the address associated with the seed defined by the default config. To modify the seed
* that is used, update the `DemoConfig.seedWords` value.
*/
class GetAddressFragment : BaseDemoFragment<FragmentGetAddressBinding>() {
private var seed: ByteArray = App.instance.defaultConfig.seed

View File

@ -8,6 +8,11 @@ import cash.z.wallet.sdk.demoapp.databinding.FragmentGetBlockBinding
import cash.z.wallet.sdk.service.LightWalletGrpcService
import cash.z.wallet.sdk.service.LightWalletService
/**
* Retrieves a compact block from the lightwalletd service and displays basic information about it.
* This demonstrates the basic ability to connect to the server, request a compact block and parse
* the response.
*/
class GetBlockFragment : BaseDemoFragment<FragmentGetBlockBinding>() {
private val host = App.instance.defaultConfig.host
private val port = App.instance.defaultConfig.port

View File

@ -8,6 +8,12 @@ import cash.z.wallet.sdk.demoapp.databinding.FragmentGetBlockRangeBinding
import cash.z.wallet.sdk.service.LightWalletGrpcService
import cash.z.wallet.sdk.service.LightWalletService
/**
* Retrieves a range of compact block from the lightwalletd service 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.
*/
class GetBlockRangeFragment : BaseDemoFragment<FragmentGetBlockRangeBinding>() {
private val host = App.instance.defaultConfig.host

View File

@ -8,6 +8,11 @@ import cash.z.wallet.sdk.demoapp.databinding.FragmentGetLatestHeightBinding
import cash.z.wallet.sdk.service.LightWalletGrpcService
import cash.z.wallet.sdk.service.LightWalletService
/**
* Retrieves the latest block height from the lightwalletd server. This is the simplest test for
* connectivity with the server. Modify the `host` and the `port` to check the SDK's ability to
* communicate with a given lightwalletd instance.
*/
class GetLatestHeightFragment : BaseDemoFragment<FragmentGetLatestHeightBinding>() {
private val host = App.instance.defaultConfig.host
private val port = App.instance.defaultConfig.port

View File

@ -6,6 +6,12 @@ import cash.z.wallet.sdk.demoapp.App
import cash.z.wallet.sdk.demoapp.BaseDemoFragment
import cash.z.wallet.sdk.demoapp.databinding.FragmentGetPrivateKeyBinding
/**
* Displays the viewing key and spending key associated with the seed defined by the default config.
* To modify the seed that is used, update the `DemoConfig.seedWords` value. This demo takes two
* approaches to deriving the seed, one that is stateless and another that is not. In most cases, a
* wallet instance will call `new` on an initializer and then store the result.
*/
class GetPrivateKeyFragment : BaseDemoFragment<FragmentGetPrivateKeyBinding>() {
private var seed: ByteArray = App.instance.defaultConfig.seed
private val initializer: Initializer = Initializer(App.instance)
@ -20,8 +26,10 @@ class GetPrivateKeyFragment : BaseDemoFragment<FragmentGetPrivateKeyBinding>() {
/*
* Initialize with the seed and retrieve one private key for each account specified (by
* default, only 1 account is created). In a normal circumstance, a wallet app would then
* store these keys in its secure storage for retrieval, later. Private keys are only needed
* for sending funds.
* store these keys in its secure storage for retrieval, later. Spending keys are only
* needed when sending funds. Viewing keys can be derived from spending keys. In most cases,
* a call to `initializer.new` or `initializer.import` are the only time a wallet passes the
* seed to the SDK. From that point forward, only spending or viewing keys are needed.
*/
spendingKeys = initializer.new(seed, birthday)

View File

@ -8,11 +8,15 @@ import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.demoapp.App
import cash.z.wallet.sdk.demoapp.R
import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.twig
/**
* The landing page for the demo. Every time the app returns to this screen, it clears all demo
* data just for sanity. The goal is for each demo to be self-contained so that the behavior is
* repeatable and independent of pre-existing state.
*/
class HomeFragment : Fragment() {
private lateinit var homeViewModel: HomeViewModel

View File

@ -18,8 +18,11 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/**
* List all transactions from a given seed and birthdate, defined in the Injector class which is
* intended to mimic dependency injection.
* List all transactions related to the given seed, since the given birthday. This begins by
* downloading any missing blocks and then validating and scanning their contents. Once scan is
* complete, the transactions are available in the database and can be accessed by any SQL tool.
* By default, the SDK uses a PagedTransactionRepository to provide transaction contents from the
* database in a paged format that works natively with RecyclerViews.
*/
class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBinding>() {
private val config = App.instance.defaultConfig

View File

@ -7,6 +7,9 @@ import androidx.recyclerview.widget.DiffUtil
import cash.z.wallet.sdk.demoapp.R
import cash.z.wallet.sdk.entity.ConfirmedTransaction
/**
* Simple adapter implementation that knows how to bind a recyclerview to ClearedTransactions.
*/
class TransactionAdapter<T : ConfirmedTransaction> :
PagedListAdapter<T, TransactionViewHolder<T>>(
object : DiffUtil.ItemCallback<T>() {

View File

@ -9,6 +9,9 @@ import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import java.text.SimpleDateFormat
import java.util.*
/**
* Simple view holder for displaying confirmed transactions in the recyclerview.
*/
class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val amountText = itemView.findViewById<TextView>(R.id.text_transaction_amount)
private val timeText = itemView.findViewById<TextView>(R.id.text_transaction_timestamp)

View File

@ -15,6 +15,14 @@ import cash.z.wallet.sdk.demoapp.util.SampleStorageBridge
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.ext.*
/**
* Demonstrates sending funds to an address. This is the most complex example that puts all of the
* pieces of the SDK together, including monitoring transactions for completion. It begins by
* downloading, validating and scanning any missing blocks. Once that is complete, the wallet is
* in a SYNCED state and available to send funds. Calling `sendToAddress` produces a flow of
* PendingTransaction objects which represent the active state of the transaction that was sent.
* Any time the state of that transaction changes, a new instance will be emitted.
*/
class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
private val config = App.instance.defaultConfig
private val initializer = Initializer(App.instance, host = config.host, port = config.port)

View File

@ -7,6 +7,13 @@ import io.github.novacrypto.bip39.Words
import io.github.novacrypto.bip39.wordlists.English
import java.security.SecureRandom
/**
* A sample implementation of a plugin for handling Mnemonic phrases. Any library can easily be
* plugged into the SDK in this manner. In this case, we are wrapping a few example 3rd party
* libraries with a thin layer that converts from their API to ours via the MnemonicPlugin
* interface. We do not endorse these libraries, rather we just use them as an example of how to
* take existing infrastructure and plug it into the SDK.
*/
class SimpleMnemonics : MnemonicPlugin {
override fun nextEntropy(): ByteArray {

View File

@ -16,7 +16,6 @@ data class DemoConfig(
val toAddress: String = "zs1lcdmue7rewgvzh3jd09sfvwq3sumu6hkhpk53q94kcneuffjkdg9e3tyxrugkmpza5c3c5e6eqh"
) {
val seed: ByteArray get() = SimpleMnemonics().toSeed(seedWords.toCharArray())
fun newWalletBirthday() = Initializer.DefaultBirthdayStore.loadBirthdayFromAssets(App.instance)
fun loadBirthday(height: Int = birthdayHeight) = Initializer.DefaultBirthdayStore.loadBirthdayFromAssets(App.instance, height)
}

View File

@ -19,6 +19,15 @@ import kotlin.reflect.KProperty
* synchronizing begins. This begins with one of three actions, a call to either [new], [import] or
* [open], where the last option is the most common case--when a user is opening a wallet they have
* used before on this device.
*
* @param appContext the application context, used to extract the storage paths for the databases
* and param files. A reference to the context is not held beyond initialization.
* @param host the host that the synchronizer should use.
* @param port the port that the synchronizer should use when connecting to the host.
* @param alias the alias to use for this synchronizer. Think of it as a unique name that allows
* multiple synchronizers to function in the same app. The alias is mapped to database names for the
* cache and data DBs. This value is optional and is usually not required because most apps only
* need one synchronizer.
*/
class Initializer(
appContext: Context,
@ -86,6 +95,8 @@ class Initializer(
* DB.
* @throws InitializerException.AlreadyInitializedException when the blocks table already exists
* and [clearDataDb] is false.
*
* @return the spending key(s) associated with this wallet, for convenience.
*/
fun new(
seed: ByteArray,
@ -116,6 +127,8 @@ class Initializer(
* DB.
* @throws InitializerException.AlreadyInitializedException when the blocks table already exists
* and [clearDataDb] is false.
*
* @return the spending key(s) associated with this wallet, for convenience.
*/
fun import(
seed: ByteArray,
@ -136,6 +149,10 @@ class Initializer(
* wallet will use. This height helps with determining where to start downloading as well as how
* far back to go during a rewind. Every wallet has a birthday and the initializer depends on
* this value but does not own it.
*
* @return an instance of this class so that the function can be used fluidly. Spending keys are
* not returned because the SDK does not store them and this function is for opening a wallet
* that was created previously.
*/
fun open(birthday: WalletBirthday): Initializer {
twig("Opening wallet with birthday ${birthday.height}")
@ -150,6 +167,25 @@ class Initializer(
* simply hold the address and viewing key for each account, which simplifies the process of
* scanning and decrypting compact blocks.
*
* @param seed the seed to use for initializing accounts. We derive the address and the viewing
* key(s) from this seed and also return the related spending key(s). Only the viewing key is
* retained in the database in order to simplify scanning for the wallet.
* @param birthday the birthday to use for this wallet. This is used in order to seed the data
* DB with the first sapling tree, which also determines where the SDK begins downloading and
* scanning. Any blocks lower than the height represented by this birthday can safely be ignored
* since a wallet cannot have transactions prior to it's creation.
* @param numberOfAccounts the number of accounts to create. Only 1 account is tested and
* supported at this time. It is possible, although unlikely that multiple accounts would behave
* as expected. Due to the nature of shielded address, the official Zcash recommendation is to
* only use one address for shielded transactions. Unlike transparent coins, address rotation is
* not necessary for shielded Zcash transactions because the sensitive information is private.
* @param clearCacheDb when true, the cache DB will be deleted prior to initializing accounts.
* This is useful for preventing errors when the database already exists, which happens often
* in tests, demos and proof of concepts.
* @param clearDataDb when true, the cache DB will be deleted prior to initializing accounts.
* This is useful for preventing errors when the database already exists, which happens often
* in tests, demos and proof of concepts.
*
* @return the spending keys for each account, ordered by index. These keys are only needed for
* spending funds.
*/
@ -207,7 +243,9 @@ class Initializer(
/**
* Internal function used to initialize the [rustBackend] before use. Initialization should only
* happen as a result of [new], [import] or [open] being called or as part of stand-alone key
* derivation.
* derivation. This involves loading the shared object file via `System.loadLibrary`.
*
* @return the rustBackend that was loaded by this initializer.
*/
private fun requireRustBackend(): RustBackend {
if (!isInitialized) {
@ -226,6 +264,10 @@ class Initializer(
* Given a seed and a number of accounts, return the associated spending keys. These keys can
* be used to derive the viewing keys.
*
* @param seed the seed from which to derive spending keys.
* @param numberOfAccounts the number of accounts to use. Multiple accounts are not fully
* supported so the default value of 1 is recommended.
*
* @return the spending keys that correspond to the seed, formatted as Strings.
*/
fun deriveSpendingKeys(seed: ByteArray, numberOfAccounts: Int = 1): Array<String> =
@ -234,6 +276,10 @@ class Initializer(
/**
* Given a seed and a number of accounts, return the associated viewing keys.
*
* @param seed the seed from which to derive viewing keys.
* @param numberOfAccounts the number of accounts to use. Multiple accounts are not fully
* supported so the default value of 1 is recommended.
*
* @return the viewing keys that correspond to the seed, formatted as Strings.
*/
fun deriveViewingKeys(seed: ByteArray, numberOfAccounts: Int = 1): Array<String> =
@ -242,6 +288,8 @@ class Initializer(
/**
* Given a spending key, return the associated viewing key.
*
* @param spendingKey the key from which to derive the viewing key.
*
* @return the viewing key that corresponds to the spending key.
*/
fun deriveViewingKey(spendingKey: String): String =
@ -250,6 +298,10 @@ class Initializer(
/**
* Given a seed and account index, return the associated address.
*
* @param seed the seed from which to derive the address.
* @param accountIndex the index of the account to use for deriving the address. Multiple
* accounts are not fully supported so the default value of 1 is recommended.
*
* @return the address that corresponds to the seed and account index.
*/
fun deriveAddress(seed: ByteArray, accountIndex: Int = 0) =
@ -258,6 +310,9 @@ class Initializer(
/**
* Given a viewing key string, return the associated address.
*
* @param viewingKey the viewing key to use for deriving the address. The viewing key is tied to
* a specific account so no account index is required.
*
* @return the address that corresponds to the viewing key.
*/
fun deriveAddress(viewingKey: String) =
@ -270,9 +325,20 @@ class Initializer(
// Path Helpers
//
/**
* Returns the path to the cache database that would correspond to the given alias.
*
* @param appContext the application context
* @param alias the alias to convert into a database path
*/
fun cacheDbPath(appContext: Context, alias: String): String =
aliasToPath(appContext, alias, ZcashSdk.DB_CACHE_NAME)
/**
* Returns the path to the data database that would correspond to the given alias.
* @param appContext the application context
* @param alias the alias to convert into a database path
*/
fun dataDbPath(appContext: Context, alias: String): String =
aliasToPath(appContext, alias, ZcashSdk.DB_DATA_NAME)
@ -287,7 +353,12 @@ class Initializer(
/**
* Model object for holding wallet birthday. It is only used by this class.
* Model object for holding a wallet birthday. It is only used by this class.
*
* @param height the height at the time the wallet was born.
* @param hash the hash of the block at the height.
* @param time the block time at the height.
* @param tree the sapling tree corresponding to the height.
*/
data class WalletBirthday(
val height: Int = -1,
@ -296,27 +367,66 @@ class Initializer(
val tree: String = ""
)
/**
* Interface for classes that can handle birthday storage. This makes it possible to bridge into
* existing storage logic. Instances of this interface can also be used as property delegates,
* which enables the syntax `val birthday by birthdayStore`
*/
interface WalletBirthdayStore : ReadWriteProperty<R, WalletBirthday> {
val newWalletBirthday: WalletBirthday
/**
* Get the birthday of the wallet, saved in this store.
*/
fun getBirthday(): WalletBirthday
/**
* Set the birthday of the wallet to be saved in this store.
*/
fun setBirthday(value: WalletBirthday)
/**
* Load a birthday matching the given height. This is most commonly used during import to
* find the first available checkpoint that is lower than the requested height.
*
* @param birthdayHeight the height to use as an upper bound for loading.
*/
fun loadBirthday(birthdayHeight: Int): WalletBirthday
/**
* Return true when a birthday has been stored in this instance.
*/
fun hasExistingBirthday(): Boolean
/**
* Return true when a birthday was imported into this instance.
*/
fun hasImportedBirthday(): Boolean
/* Property implementation that allows this interface to be used as a property delegate */
/**
* Implement readable interface in order to be able to use instances of this interface as
* property delegates.
*/
override fun getValue(thisRef: R, property: KProperty<*>): WalletBirthday {
return getBirthday()
}
/**
* Implement writable interface in order to be able to use instances of this interface as
* property delegates.
*/
override fun setValue(thisRef: R, property: KProperty<*>, value: WalletBirthday) {
setBirthday(value)
}
}
/**
* Default implementation of the [WalletBirthdayStore] interface that loads checkpoints from the
* assets directory, in JSON format and stores the current birthday in shared preferences.
*/
class DefaultBirthdayStore(
private val appContext: Context,
private val importedBirthdayHeight: Int? = null,
@ -370,6 +480,8 @@ class Initializer(
* is there, the rest will be too. If that's not the case, a call to this function will
* result in an exception.
*
* @param prefs the shared preference to use for loading the birthday.
*
* @return a birthday from preferences if one exists and null, otherwise null
*/
private fun loadBirthdayFromPrefs(prefs: SharedPreferences?): WalletBirthday? {
@ -390,7 +502,7 @@ class Initializer(
/**
* Save the given birthday to the given preferences.
*
* @param prefs the shared preferences to use
* @param prefs the shared preferences to use for saving the birthday.
* @param birthday the birthday to save. It will be split into primitives.
*/
private fun saveBirthdayToPrefs(prefs: SharedPreferences, birthday: WalletBirthday) {
@ -423,16 +535,37 @@ class Initializer(
*/
private const val BIRTHDAY_DIRECTORY = "zcash/saplingtree"
/**
* The default alias to use for naming the preference file used for storage.
*/
const val DEFAULT_ALIAS = "default_prefs"
// Constructor function
/**
* A convenience constructor function for creating an instance of this class to use for
* new wallets. It sets the stored birthday to match the `newWalletBirthday` checkpoint
* which is typically the most recent checkpoint available.
*
* @param appContext the application context.
* @param alias the alias to use when naming the preferences file used for storage.
*/
fun NewWalletBirthdayStore(appContext: Context, alias: String = DEFAULT_ALIAS): WalletBirthdayStore {
return DefaultBirthdayStore(appContext, alias = alias).apply {
setBirthday(newWalletBirthday)
}
}
// Constructor function
/**
* A convenience constructor function for creating an instance of this class to use for
* imported wallets. It sets the stored birthday to match the given
* `importedBirthdayHeight` by finding the highest checkpoint that is below that height.
*
* @param appContext the application context.
* @param importedBirthdayHeight the height corresponding to the birthday of the wallet
* being imported. A checkpoint will be generated that allows scanning to start as close
* to this height as possible because any blocks before this height can safely be
* ignored since a wallet cannot have transactions before it is born.
* @param alias the alias to use when naming the preferences file used for storage.
*/
fun ImportedWalletBirthdayStore(appContext: Context, importedBirthdayHeight: Int?, alias: String = DEFAULT_ALIAS): WalletBirthdayStore {
return DefaultBirthdayStore(appContext, alias = alias).apply {
if (importedBirthdayHeight != null) {
@ -504,6 +637,8 @@ class Initializer(
* permit the alias to be used as part of a file name for the preferences and databases. This
* enables multiple wallets to exist on one device, which is also helpful for sweeping funds.
*
* @param alias the alias to validate.
*
* @throws IllegalArgumentException whenever the alias is not less than 100 characters or
* contains something other than alphanumeric characters. Underscores are allowed but aliases
* must start with a letter.

View File

@ -32,9 +32,9 @@ import kotlin.coroutines.CoroutineContext
* pieces can be tied together. Its goal is to allow a developer to focus on their app rather than
* the nuances of how Zcash works.
*
* @param ledger exposes flows of wallet transaction information.
* @param manager manages and tracks outbound transactions.
* @param processor saves the downloaded compact blocks to the cache and then scans those blocks for
* @property ledger exposes flows of wallet transaction information.
* @property manager manages and tracks outbound transactions.
* @property processor saves the downloaded compact blocks to the cache and then scans those blocks for
* data related to this wallet.
*/
@ExperimentalCoroutinesApi
@ -144,6 +144,8 @@ class SdkSynchronizer internal constructor(
* scope is only used for launching this synchronzer's job as a child. If no scope is provided,
* then this synchronizer and all of its coroutines will run until stop is called, which is not
* recommended since it can leak resources. That type of behavior is more useful for tests.
*
* @return an instance of this class so that this function can be used fluidly.
*/
override fun start(parentScope: CoroutineScope?): Synchronizer {
if (::coroutineScope.isInitialized) throw SynchronizerException.FalseStart
@ -181,6 +183,10 @@ class SdkSynchronizer internal constructor(
ledger.invalidate()
}
/**
* Calculate the latest balance, based on the blocks that have been scanned and transmit this
* information into the flow of [balances].
*/
suspend fun refreshBalance() {
twig("refreshing balance")
_balances.send(processor.getBalanceInfo())
@ -357,9 +363,19 @@ class SdkSynchronizer internal constructor(
}
/**
* Simplest constructor possible. Useful for demos, sample apps or PoC's. Anything more complex
* A convenience constructor that accepts the information most likely to change and uses defaults
* for everything else. This is useful for demos, sample apps or PoC's. Anything more complex
* will probably want to handle initialization, directly.
*
* @param appContext the application context. This is mostly used for finding databases and params
* files within the apps secure storage area.
* @param lightwalletdHost the lightwalletd host to use for connections.
* @param lightwalletdPort the lightwalletd port to use for connections.
* @param seed the seed to use for this wallet, when importing. Null when creating a new wallet.
* @param birthdayStore the place to store the birthday of this wallet for future reference, which
* allows something else to manage the state on behalf of the initializer.
*/
@Suppress("FunctionName")
fun Synchronizer(
appContext: Context,
lightwalletdHost: String = ZcashSdk.DEFAULT_LIGHTWALLETD_HOST,
@ -387,6 +403,20 @@ fun Synchronizer(
return Synchronizer(appContext, initializer)
}
/**
* Constructor function to use in most cases. This is a convenience function for when a wallet has
* already created an initializer. Meaning, the basic flow is to call either [Initializer.new] or
* [Initializer.import] on the first run and then [Initializer.open] for all subsequent launches of
* the wallet. From there, the initializer is passed to this function in order to start syncing from
* where the wallet left off.
*
* @param appContext the application context. This is mostly used for finding databases and params
* files within the apps secure storage area.
* @param initializer the helper that is leveraged for creating all the components that the
* Synchronizer requires. It is mainly responsible for initializing the databases associated with
* this synchronizer.
*/
@Suppress("FunctionName")
fun Synchronizer(
appContext: Context,
initializer: Initializer
@ -400,7 +430,24 @@ fun Synchronizer(
/**
* Constructor function for building a Synchronizer in the most flexible way possible. This allows
* a wallet maker to customize any subcomponent of the Synchronzier.
* a wallet maker to customize any subcomponent of the Synchronzer.
*
* @param appContext the application context. This is mostly used for finding databases and params
* files within the apps secure storage area.
* @param lightwalletdHost the lightwalletd host to use for connections.
* @param lightwalletdPort the lightwalletd port to use for connections.
* @param ledger repository of wallet transactions, providing an agnostic interface to the
* underlying information.
* @param blockStore component responsible for storing compact blocks downloaded from lightwalletd.
* @param service the lightwalletd service that can provide compact blocks and submit transactions.
* @param encoder the component responsible for encoding transactions.
* @param downloader the component responsible for downloading ranges of compact blocks.
* @param manager the component that manages outbound transactions in order to report which ones are
* still pending, particularly after failed attempts or dropped connectivity. The intent is to help
* monitor outbound transactions status through to completion.
* @param processor the component responsible for processing compact blocks. This is effectively the
* brains of the synchronizer that implements most of the high-level business logic and determines
* the current state of the wallet.
*/
@Suppress("FunctionName")
fun Synchronizer(

View File

@ -25,6 +25,8 @@ interface Synchronizer {
* @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.
*
* @return an instance of the class so that this function can be used fluidly.
*/
fun start(parentScope: CoroutineScope? = null): Synchronizer
@ -99,6 +101,8 @@ interface Synchronizer {
*
* @param accountId the optional accountId whose address is of interest. By default, the first
* account is used.
*
* @return the address for the given account.
*/
suspend fun getAddress(accountId: Int = 0): String
@ -110,6 +114,11 @@ interface Synchronizer {
* @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.
*
* @return a flow of PendingTransaction objects representing changes to the state of the
* transaction. Any time the state changes a new instance will be emitted by this flow. This is
* useful for updating the UI without needing to poll. Of course, polling is always an option
* for any wallet that wants to ignore this return value.
*/
fun sendToAddress(
spendingKey: String,
@ -122,26 +131,38 @@ interface Synchronizer {
/**
* Returns true when the given address is a valid z-addr. Invalid addresses will throw an
* exception. Valid z-addresses have these characteristics: //TODO
* exception. Valid z-addresses have these characteristics: //TODO copy info from related ZIP
*
* @param address the address to validate.
*
* @return true when the given address is a valid z-addr.
*
* @throws RuntimeException when the address is invalid.
*/
suspend fun isValidShieldedAddr(address: String): Boolean
/**
* Returns true when the given address is a valid t-addr. Invalid addresses will throw an
* exception. Valid t-addresses have these characteristics: //TODO
* exception. Valid t-addresses have these characteristics: //TODO copy info from related ZIP
*
* @param address the address to validate.
*
* @return true when the given address is a valid t-addr.
*
* @throws RuntimeException when the address is invalid.
*/
suspend fun isValidTransparentAddr(address: String): Boolean
/**
* Validates the given address, returning information about why it is invalid.
* Validates the given address, returning information about why it is invalid. This is a
* convenience method that combines the behavior of [isValidShieldedAddr] and
* [isValidTransparentAddr] into one call so that the developer doesn't have to worry about
* handling the exceptions that they throw. Rather, exceptions are converted to
* [AddressType.Invalid] which has a `reason` property describing why it is invalid.
*
* @param address the address to validate.
*
* @return an instance of [AddressType] providing validation info regarding the given address.
*/
suspend fun validateAddress(address: String): AddressType
@ -150,6 +171,7 @@ interface Synchronizer {
* 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.
*/
suspend fun cancelSpend(transaction: PendingTransaction): Boolean
@ -193,7 +215,9 @@ interface Synchronizer {
*/
var onChainErrorHandler: ((Int, Int) -> Any)?
/**
* Represents the status of this Synchronizer, which is useful for communicating to the user.
*/
enum class Status {
/**
* Indicates that [stop] has been called on this Synchronizer and it will no longer be used.
@ -231,12 +255,35 @@ interface Synchronizer {
SYNCED
}
/**
* Represents the types of addresses, either Shielded, Transparent or Invalid.
*/
sealed class AddressType {
/**
* Marker interface for valid [AddressType] instances.
*/
interface Valid
/**
* An instance of [AddressType] corresponding to a valid z-addr.
*/
object Shielded : Valid, AddressType()
/**
* An instance of [AddressType] corresponding to a valid t-addr.
*/
object Transparent : Valid, AddressType()
/**
* An instance of [AddressType] corresponding to an invalid address.
*
* @param reason a descrption of why the address was invalid.
*/
class Invalid(val reason: String = "Invalid") : AddressType()
/**
* A convenience method that returns true when an instance of this class is invalid.
*/
val isNotValid get() = this !is Valid
}

View File

@ -6,14 +6,20 @@ import androidx.room.RoomDatabase
import cash.z.wallet.sdk.db.CompactBlockDao
import cash.z.wallet.sdk.db.CompactBlockDb
import cash.z.wallet.sdk.entity.CompactBlockEntity
import cash.z.wallet.sdk.ext.ZcashSdk.DB_CACHE_NAME
import cash.z.wallet.sdk.ext.ZcashSdk.SAPLING_ACTIVATION_HEIGHT
import cash.z.wallet.sdk.rpc.CompactFormats
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
/**
* An implementation of CompactBlockStore that persists information to a database in the given
* path. This represents the "cache db" or local cache of compact blocks waiting to be scanned.
*
* @param appContext the application context. This is used for creating the database.
* @property dbPath the absolute path to the database.
*/
class CompactBlockDbStore(
applicationContext: Context,
appContext: Context,
val dbPath: String
) : CompactBlockStore {
@ -21,12 +27,12 @@ class CompactBlockDbStore(
private val cacheDb: CompactBlockDb
init {
cacheDb = createCompactBlockCacheDb(applicationContext)
cacheDb = createCompactBlockCacheDb(appContext)
cacheDao = cacheDb.complactBlockDao()
}
private fun createCompactBlockCacheDb(applicationContext: Context): CompactBlockDb {
return Room.databaseBuilder(applicationContext, CompactBlockDb::class.java, dbPath)
private fun createCompactBlockCacheDb(appContext: Context): CompactBlockDb {
return Room.databaseBuilder(appContext, CompactBlockDb::class.java, dbPath)
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
// this is a simple cache of blocks. destroying the db should be benign
.fallbackToDestructiveMigration()

View File

@ -18,25 +18,52 @@ open class CompactBlockDownloader(
val compactBlockStore: CompactBlockStore
) {
/**
* Requests the given range of blocks from the lightwalletService 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.
*/
suspend fun downloadBlockRange(heightRange: IntRange): Int = withContext(IO) {
val result = lightwalletService.getBlockRange(heightRange)
compactBlockStore.write(result)
result.size
}
/**
* Rewind the storage to the given height, usually to handle reorgs.
*
* @param height the height to which the data will rewind.
*/
suspend fun rewindToHeight(height: Int) = withContext(IO) {
// TODO: cancel anything in flight
compactBlockStore.rewindTo(height)
}
/**
* Return the latest block height known by the lightwalletService.
*
* @return the latest block height.
*/
suspend fun getLatestBlockHeight() = withContext(IO) {
lightwalletService.getLatestBlockHeight()
}
/**
* Return the latest block height that has been persisted into the [CompactBlockStore].
*
* @return the latest block height that has been persisted.
*/
suspend fun getLastDownloadedHeight() = withContext(IO) {
compactBlockStore.getLatestHeight()
}
/**
* Stop this downloader and cleanup any resources being used.
*/
fun stop() {
lightwalletService.shutdown()
compactBlockStore.close()

View File

@ -33,6 +33,10 @@ import kotlin.math.roundToInt
* all the business logic required to validate and scan the blockchain and is therefore tightly coupled with
* librustzcash.
*
* @property downloader the component responsible for downloading compact blocks and persisting them
* locally for processing.
* @property repository the repository holding transaction information.
* @property rustBackend the librustzcash functionality available and exposed to the SDK.
* @param minimumHeight the lowest height that we could care about. This is mostly used during
* reorgs as a backstop to make sure we do not rewind beyond sapling activation. It also is factored
* in when considering initial range to download. In most cases, this should be the birthday height
@ -45,7 +49,15 @@ class CompactBlockProcessor(
private val rustBackend: RustBackendWelding,
minimumHeight: Int = SAPLING_ACTIVATION_HEIGHT
) {
/**
* Callback for any critical errors that occur while processing compact blocks.
*/
var onProcessorErrorListener: ((Throwable) -> Boolean)? = null
/**
* Callbaqck for reorgs. This callback is invoked when validation fails with the height at which
* an error was found and the lower bound to which the data will rewind, at most.
*/
var onChainErrorListener: ((Int, Int) -> Any)? = null
private val consecutiveChainErrors = AtomicInteger(0)
@ -62,12 +74,26 @@ class CompactBlockProcessor(
*/
private var currentInfo = ProcessorInfo()
/**
* The flow of state values so that a wallet can monitor the state of this class without needing
* to poll.
*/
val state = _state.asFlow()
/**
* The flow of progress values so that a wallet can monitor how much downloading remains
* without needing to poll.
*/
val progress = _progress.asFlow()
/**
* The flow of detailed processorInfo like the range of blocks that shall be downloaded and
* scanned. This gives the wallet a lot of insight into the work of this processor.
*/
val processorInfo = _processorInfo.asFlow()
/**
* Download compact blocks, verify and scan them.
* Download compact blocks, verify and scan them until [stop] is called.
*/
suspend fun start() = withContext(IO) {
twig("processor starting")
@ -165,6 +191,8 @@ class CompactBlockProcessor(
* in ascending order, with no gaps and are also chain-sequential. This means every block's
* prevHash value matches the preceding block in the chain.
*
* @param lastScanRange the range to be validated and scanned.
*
* @return error code or -1 when there is no error.
*/
private suspend fun validateAndScanNewBlocks(lastScanRange: IntRange): Int = withContext(IO) {
@ -194,7 +222,9 @@ class CompactBlockProcessor(
}
/**
* Download all blocks in the given range.
* Request all blocks in the given range and persist them locally for processing, later.
*
* @param range the range of blocks to download.
*/
@VisibleForTesting //allow mocks to verify how this is called, rather than the downloader, which is more complex
internal suspend fun downloadNewBlocks(range: IntRange) = withContext<Unit>(IO) {
@ -234,6 +264,11 @@ class CompactBlockProcessor(
* Validate all blocks in the given range, ensuring that the blocks are in ascending order, with
* no gaps and are also chain-sequential. This means every block's prevHash value matches the
* preceding block in the chain.
*
* @param range the range of blocks to validate.
*
* @return -1 when there is not problem. Otherwise, return the lowest height where an error was
* found. In other words, validation starts at the back of the chain and works toward the tip.
*/
private fun validateNewBlocks(range: IntRange?): Int {
if (range?.isEmpty() != false) {
@ -248,8 +283,13 @@ class CompactBlockProcessor(
}
/**
* Scan all blocks in the given range, decrypting anything that matches our wallet and storing
* the data.
* Scan all blocks in the given range, decrypting and persisting anything that matches our
* wallet.
*
* @param range the range of blocks to scan.
*
* @return -1 when there is not problem. Otherwise, return the lowest height where an error was
* found. In other words, scanning starts at the back of the chain and works toward the tip.
*/
private suspend fun scanNewBlocks(range: IntRange?): Boolean = withContext(IO) {
if (range?.isEmpty() != false) {
@ -282,6 +322,13 @@ class CompactBlockProcessor(
}
/**
* Emit an instance of processorInfo, corresponding to the provided data.
*
* @param networkBlockHeight the latest block available to lightwalletd that may or may not be
* downloaded by this wallet yet.
* @param lastScannedHeight the height up to which the wallet last scanned. This determines
* where the next scan will begin.
* @param lastDownloadedHeight the last compact block that was successfully downloaded.
* @param lastScanRange the inclusive range to scan. This represents what we most recently
* wanted to scan. In most cases, it will be an invalid range because we'd like to scan blocks
* that we don't yet have.
@ -326,14 +373,29 @@ class CompactBlockProcessor(
}
}
/**
* Get the height of the last block that was downloaded by this processor.
*
* @return the last downloaded height reported by the downloader.
*/
suspend fun getLastDownloadedHeight() = withContext(IO) {
downloader.getLastDownloadedHeight()
}
/**
* Get the height of the last block that was scanned by this processor.
*
* @return the last scanned height reported by the repository.
*/
suspend fun getLastScannedHeight() = withContext(IO) {
repository.lastScannedHeight()
}
/**
* Get address corresponding to the given account for this wallet.
*
* @return the address of this wallet.
*/
suspend fun getAddress(accountId: Int) = withContext(IO) {
rustBackend.getAddress(accountId)
}
@ -342,6 +404,8 @@ class CompactBlockProcessor(
* Calculates the latest balance info. Defaults to the first account.
*
* @param accountIndex the account to check for balance info.
*
* @return an instance of WalletBalance containing information about available and total funds.
*/
suspend fun getBalanceInfo(accountIndex: Int = 0): WalletBalance = withContext(IO) {
twigTask("checking balance info") {
@ -358,19 +422,65 @@ class CompactBlockProcessor(
}
}
suspend fun setState(newState: State) {
/**
* Transmits the given state for this processor.
*/
private suspend fun setState(newState: State) {
_state.send(newState)
}
/**
* Sealed class representing the various states of this processor.
*/
sealed class State {
/**
* Marker interface for [State] instances that represent when the wallet is connected.
*/
interface Connected
/**
* Marker interface for [State] instances that represent when the wallet is syncing.
*/
interface Syncing
/**
* [State] for when the wallet is actively downloading compact blocks because the latest
* block height available from the server is greater than what we have locally. We move out
* of this state once our local height matches the server.
*/
object Downloading : Connected, Syncing, State()
/**
* [State] for when the blocks that have been downloaded are actively being validated to
* ensure that there are no gaps and that every block is chain-sequential to the previous
* block, which determines whether a reorg has happened on our watch.
*/
object Validating : Connected, Syncing, State()
/**
* [State] for when the blocks that have been downloaded are actively being decrypted.
*/
object Scanning : Connected, Syncing, State()
/**
* [State] for when we are done decrypting blocks, for now.
*/
class Scanned(val scannedRange:IntRange) : Connected, Syncing, State()
/**
* [State] for when we have no connection to lightwalletd.
*/
object Disconnected : State()
/**
* [State] for when [stop] has been called. For simplicity, processors should not be
* restarted but they are not prevented from this behavior.
*/
object Stopped : State()
/**
* [State] the initial state of the processor, once it is constructed.
*/
object Initialized : State()
}
@ -390,6 +500,14 @@ class CompactBlockProcessor(
)
/**
* Data class for holding detailed information about the processor.
*
* @param networkBlockHeight the latest block available to lightwalletd that may or may not be
* downloaded by this wallet yet.
* @param lastScannedHeight the height up to which the wallet last scanned. This determines
* where the next scan will begin.
* @param lastDownloadedHeight the last compact block that was successfully downloaded.
*
* @param lastDownloadRange inclusive range to download. Meaning, if the range is 10..10,
* then we will download exactly block 10. If the range is 11..10, then we want to download
* block 11 but can't.
@ -404,7 +522,9 @@ class CompactBlockProcessor(
) {
/**
* Returns false when all values match their defaults.
* Determines whether this instance has data.
*
* @return false when all values match their defaults.
*/
val hasData get() = networkBlockHeight != -1
|| lastScannedHeight != -1
@ -413,13 +533,17 @@ class CompactBlockProcessor(
|| lastScanRange != 0..-1
/**
* Returns true when there are more than zero blocks remaining to download.
* Determines whether this instance is actively downloading compact blocks.
*
* @return true when there are more than zero blocks remaining to download.
*/
val isDownloading: Boolean get() = !lastDownloadRange.isEmpty()
&& lastDownloadedHeight < lastDownloadRange.last
/**
* Returns true when downloading has completed and there are more than zero blocks remaining
* Determines whether this instance is actively scanning or validating compact blocks.
*
* @return true when downloading has completed and there are more than zero blocks remaining
* to be scanned.
*/
val isScanning: Boolean get() = !isDownloading

View File

@ -8,11 +8,15 @@ import cash.z.wallet.sdk.rpc.CompactFormats
interface CompactBlockStore {
/**
* Gets the highest block that is currently stored.
*
* @return the latest block height.
*/
suspend fun getLatestHeight(): Int
/**
* Write the given blocks to this store, which may be anything from an in-memory cache to a DB.
*
* @param result the list of compact blocks to persist.
*/
suspend fun write(result: List<CompactFormats.CompactBlock>)
@ -21,6 +25,8 @@ interface CompactBlockStore {
*
* After this operation, the data store will look the same as one that has not yet stored the given block height.
* Meaning, if max height is 100 block and rewindTo(50) is called, then the highest block remaining will be 49.
*
* @param height the target height to which to rewind.
*/
suspend fun rewindTo(height: Int)

View File

@ -4,6 +4,9 @@ import androidx.paging.PagedList
import cash.z.wallet.sdk.entity.*
import kotlinx.coroutines.flow.Flow
/**
* Repository of wallet transactions, providing an agnostic interface to the underlying information.
*/
interface TransactionRepository {
fun lastScannedHeight(): Int
fun isInitialized(): Boolean