Merge pull request #110 from zcash/feature/documentation-update

Feature/documentation update
This commit is contained in:
Kevin Gorham 2020-02-27 13:51:50 -05:00 committed by GitHub
commit adc05fb6b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1178 additions and 503 deletions

View File

@ -106,17 +106,39 @@ synchronizer.sendToAddress(spendingKey, zatoshi, address, memo)
## Compiling Sources
:warning: Presently, the latest stable code lives in the `master` branch, is under active development, and is not yet released.
:warning: Compilation is not required unless you plan to submit a patch or fork the code. Instead, it is recommended to simply add the SDK dependencies via gradle.
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:
In the event that you *do* want to compile the SDK from sources, follow these steps:
1. [Install rust](https://www.rust-lang.org/learn/get-started)
2. Then, add the android targets via:
```bash
./gradlew clean assembleZcashtestnetRelease
rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android
```
3. Clone this repo
4. [Install android studio](https://developer.android.com/studio/install) and open this project via `/your/path/to/zcash-android-wallet-sdk/build.gradle`
5. Open Android Studios SDK manager
<p align="center">
<img src="assets/sdk-manager-icon.png?raw=true" width="70%"/>
</p>
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.
6. Then, install NDK 20.0.5594570
<p align="center">
<img src="assets/ndk-window.png?raw=true" width="85%"/>
</p>
7. [Create an emulator](https://developer.android.com/studio/run/managing-avds) if you dont already have one (recommended target: API 29)
8. Select your desired build variant. Currently, we recommend `zcashmainnetDebug` as the testnet variants are slower to sync to current height due to a lack of checkpoints.
<p align="center">
<img src="assets/build-variants.png?raw=true" width="54%"/>
</p>
9. Sync project with Gradle files, and build from the IDE. Alternatively, to build from the command line run:
```bash
./gradlew clean assembleZcashmainnetDebug
```
This creates a build of the SDK under `build/outputs/aar/` that can be used to preview functionality. For more detailed examples, checkout the [demo app](samples/demo-app). Note that merely using the SDK does not require installing Rust or Cargo--that is only required when compiling from source.
[Back to contents](#contents)

BIN
assets/build-variants.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
assets/ndk-window.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

BIN
assets/sdk-manager-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@ -0,0 +1,74 @@
# Android demo app
This is a demo app that exercises code in https://github.com/zcash/zcash-android-wallet-sdk, which has all the Android-related functionalities necessary to build a mobile Zcash shielded wallet.
It relies on [Lightwalletd](https://github.com/zcash/lightwalletd), a backend service that provides a bandwidth-efficient interface to the Zcash blockchain. There is an equivalent [iOS demo app](https://github.com/zcash/ZcashLightClientKit).
## Contents
- [Requirements](#requirements)
- [Installation](#installation)
- [Exploring the demo app](#exploring-the-demo-app)
- [Demos](#demos)
- [Getting started](#getting-started)
- [Resources](#resources)
## Requirements
The demo app is built in Kotlin, and targets API 21. The demo directly links the SDK source code so building it also builds the SDK and therefore requires Rust and the NDK.
[Back to contents](#contents)
## Installation
Refer to [build instructions](https://github.com/zcash/zcash-android-wallet-sdk#compiling-sources) in the readme of the android-wallet-sdk repository for detailed instructions. In short, you will need to:
1. Install rust: https://www.rust-lang.org/learn/get-started
2. Clone this repo, https://github.com/zcash/zcash-android-wallet-sdk
3. Launch from Android Studio, https://developer.android.com/studio
[Back to contents](#contents)
## Exploring the demo app
After building the app, the emulator should launch with a basic app that exercises the SDK (see picture below).
To explore the app, click on each menu item, in order, and also look at the associated code.
![The android demo app, running in Android Studio](assets/demo-app.png?raw=true "Demo App with Android Studio")
The demo app is not trying to show what's possible, but to present how to accomplish the building blocks of wallet functionality in a simple way in code. It is comprised of the following self-contained demos. All data is reset between demos in order to keep the behavior repeatable and independant of state.
### Demos
Menu Item|Related Code|Description
:-----|:-----|:-----
Get Private Key|[GetPrivateKeyFragment.kt](app/src/main/java/cash/z/wallet/sdk/demoapp/demos/getprivatekey/GetPrivateKeyFragment.kt)|Given a seed, display its viewing key and spending key
Get Address|[GetAddressFragment.kt](app/src/main/java/cash/z/wallet/sdk/demoapp/demos/getaddress/GetAddressFragment.kt)|Given a seed, display its z-addr
Get Latest Height|[GetLatestHeightFragment.kt](app/src/main/java/cash/z/wallet/sdk/demoapp/demos/getlatestheight/GetLatestHeightFragment.kt)|Given a lightwalletd server, retrieve the latest block height
Get Block|[GetBlockFragment.kt](app/src/main/java/cash/z/wallet/sdk/demoapp/demos/getblock/GetBlockFragment.kt)|Given a lightwalletd server, retrieve a compact block
Get Block Range|[GetBlockRangeFragment.kt](app/src/main/java/cash/z/wallet/sdk/demoapp/demos/getblockrange/GetBlockRangeFragment.kt)|Given a lightwalletd server, retrieve a range of compact blocks
List Transactions|[ListTransactionsFragment.kt](app/src/main/java/cash/z/wallet/sdk/demoapp/demos/listtransactions/ListTransactionsFragment.kt)|Given a seed, list all related shielded transations
Send|[SendFragment.kt](app/src/main/java/cash/z/wallet/sdk/demoapp/demos/send/SendFragment.kt)|Send and monitor a transaction, the most complex demo
[Back to contents](#contents)
## Getting started
Were assuming you already have a brilliant app idea, a vision for the apps UI, and know the ins and outs of the Android lifecycle. Well just stick to the Zcash app part of “getting started.”
Similarly, the best way to build a functioning Zcash shielded app is to implement the functionalities that are listed in the demo app, in roughly that order:
1. Generate and safely store your private key.
1. Get the associated address, and display it to the user on a receive screen. You may also want to generate a QR code from this address.
1. Make sure your app can talk to the lightwalletd server and check by asking for the latest height, and verify that its current with the Zcash network.
1. Try interacting with lightwalletd by fetching a block and processing it. Then try fetching a range of blocks, which is much more efficient.
1. Now that you have the blocks process them and list transactions that send to or are from that wallet, to calculate your balance.
1. With a current balance (and funds, of course), send a transaction and monitor its transaction status and update the UI with the results.
[Back to contents](#contents)
## Resources
You dont need to do it all on your own.
* Chat with the team who built the kit: [Zcash discord community channel, wallet](https://discord.gg/efFG7UJ)
* Discuss ideas with other community members: [Zcash forum](https://forum.zcashcommunity.com/)
* Get funded to build a Zcash app: [Zcash foundation grants program](https://grants.zfnd.org/)
* Follow Zcash-specific best practices: [Zcash wallet developer checklist](https://zcash.readthedocs.io/en/latest/rtd_pages/ux_wallet_checklist.html)
* Get more information and see FAQs about the wallet: [Shielded resources documentation](https://zcash.readthedocs.io/en/latest/rtd_pages/shielded_support.html)
[Back to contents](#contents)

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)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@ -4,7 +4,8 @@ import android.content.Context
import android.content.SharedPreferences
import cash.z.wallet.sdk.exception.BirthdayException
import cash.z.wallet.sdk.exception.InitializerException
import cash.z.wallet.sdk.ext.*
import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.jni.RustBackend
import com.google.gson.Gson
import com.google.gson.stream.JsonReader
@ -19,6 +20,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 +96,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 +128,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 +150,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 +168,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 +244,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 +265,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 +277,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 +289,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 +299,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 +311,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 +326,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 +354,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 +368,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 +481,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 +503,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 +536,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) {
@ -495,6 +629,52 @@ class Initializer(
)
}
}
/*
* Helper functions for using SharedPreferences
*/
/**
* Convenient constructor function for SharedPreferences used by this class.
*/
@Suppress("FunctionName")
private fun SharedPrefs(context: Context, name: String = "prefs"): SharedPreferences {
val fileName = "${BuildConfig.FLAVOR}.${BuildConfig.BUILD_TYPE}.$name".toLowerCase()
return context.getSharedPreferences(fileName, Context.MODE_PRIVATE)!!
}
private inline fun SharedPreferences.edit(block: (SharedPreferences.Editor) -> Unit) {
edit().run {
block(this)
apply()
}
}
private operator fun SharedPreferences.set(key: String, value: Any?) {
when (value) {
is String? -> edit { it.putString(key, value) }
is Int -> edit { it.putInt(key, value) }
is Boolean -> edit { it.putBoolean(key, value) }
is Float -> edit { it.putFloat(key, value) }
is Long -> edit { it.putLong(key, value) }
else -> throw UnsupportedOperationException("Not yet implemented")
}
}
private inline operator fun <reified T : Any> SharedPreferences.get(
key: String,
defaultValue: T? = null
): T? {
return when (T::class) {
String::class -> getString(key, defaultValue as? String) as T?
Int::class -> getInt(key, defaultValue as? Int ?: -1) as T?
Boolean::class -> getBoolean(key, defaultValue as? Boolean ?: false) as T?
Float::class -> getFloat(key, defaultValue as? Float ?: -1f) as T?
Long::class -> getLong(key, defaultValue as? Long ?: -1) as T?
else -> throw UnsupportedOperationException("Not yet implemented")
}
}
}
}
}
@ -504,6 +684,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,17 +101,24 @@ 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
/**
* Sends zatoshi.
*
* @param spendingKey the key that allows spends to occur.
* @param spendingKey the key associated with the notes that will be spent.
* @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.
* @param fromAccountIndex 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

@ -8,6 +8,12 @@ import cash.z.wallet.sdk.entity.CompactBlockEntity
// Database
//
/**
* The "Cache DB", serving as a cache of compact blocks, waiting to be processed. This will contain
* the entire blockchain, from the birthdate of the wallet, forward. The [CompactBlockProcessor]
* will copy blocks from this database, as they are scanned. In the future, those blocks can be
* deleted because they are no longer needed. Currently, this efficiency has not been implemented.
*/
@Database(
entities = [CompactBlockEntity::class],
version = 1,
@ -22,6 +28,9 @@ abstract class CompactBlockDb : RoomDatabase() {
// Data Access Objects
//
/**
* Data access object for compact blocks in the "Cache DB."
*/
@Dao
interface CompactBlockDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@ -11,6 +11,13 @@ import cash.z.wallet.sdk.entity.*
// Database
//
/**
* The "Data DB," where all data derived from the compact blocks is stored. Most importantly, this
* database contains transaction information and can be queried for the current balance. The
* "blocks" table contains a copy of everything that has been scanned. In the future, that table can
* be truncated up to the last scanned block, for storage efficiency. Wallets should only read from,
* but never write to, this database.
*/
@Database(
entities = [
TransactionEntity::class,
@ -34,6 +41,9 @@ abstract class DerivedDataDb : RoomDatabase() {
// Data Access Objects
//
/**
* The data access object for blocks, used for determining the last scanned height.
*/
@Dao
interface BlockDao {
@Query("SELECT COUNT(height) FROM blocks")
@ -43,18 +53,28 @@ interface BlockDao {
fun lastScannedHeight(): Int
}
/**
* The data access object for notes, used for determining whether transactions exist.
*/
@Dao
interface ReceivedDao {
@Query("SELECT COUNT(tx) FROM received_notes")
fun count(): Int
}
/**
* The data access object for sent notes, used for determining whether outbound transactions exist.
*/
@Dao
interface SentDao {
@Query("SELECT COUNT(tx) FROM sent_notes")
fun count(): Int
}
/**
* The data access object for transactions, used for querying all transaction information, including
* whether transactions are mined.
*/
@Dao
interface TransactionDao {
@Query("SELECT COUNT(id_tx) FROM transactions")
@ -79,12 +99,6 @@ interface TransactionDao {
""")
fun findMinedHeight(rawTransactionId: ByteArray): Int?
// @Delete
// fun delete(transaction: Transaction)
//
// @Query("DELETE FROM transactions WHERE id_tx = :id")
// fun deleteById(id: Long)
/**
* Query sent transactions that have been mined, sorted so the newest data is at the top.
*/
@ -137,6 +151,9 @@ interface TransactionDao {
""")
fun getReceivedTransactions(limit: Int = Int.MAX_VALUE): DataSource.Factory<Int, ConfirmedTransaction>
/**
* Query all transactions, joining outbound and inbound transactions into the same table.
*/
@Query("""
SELECT transactions.id_tx AS id,
transactions.block AS minedHeight,

View File

@ -9,6 +9,12 @@ import kotlinx.coroutines.flow.Flow
// Database
//
/**
* Database for pending transaction information. Unlike with the "Data DB," the wallet is free to
* write to this database. In a way, this almost serves as a local mempool for all transactions
* initiated by this wallet. Currently, the data necessary to support expired transactions is there
* but it is not being leveraged.
*/
@Database(
entities = [
PendingTransactionEntity::class
@ -25,6 +31,9 @@ abstract class PendingTransactionDb : RoomDatabase() {
// Data Access Objects
//
/**
* Data access object providing crud for pending transactions.
*/
@Dao
interface PendingTransactionDao {
@Insert(onConflict = OnConflictStrategy.ABORT)

View File

@ -19,17 +19,27 @@ sealed class RustLayerException(message: String, cause: Throwable? = null) : Sdk
"blocks are not missing or have not been scanned out of order.", cause)
}
/**
* User-facing exceptions thrown by the transaction repository.
*/
sealed class RepositoryException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object FalseStart: RepositoryException( "The channel is closed. Note that once a repository has stopped it " +
"cannot be restarted. Verify that the repository is not being restarted.")
}
/**
* High-level exceptions thrown by the synchronizer, which do not fall within the umbrealla of a
* child component.
*/
sealed class SynchronizerException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object FalseStart: SynchronizerException("This synchronizer was already started. Multiple calls to start are not" +
"allowed and once a synchronizer has stopped it cannot be restarted."
)
}
/**
* Potentially user-facing exceptions that occur while processing compact blocks.
*/
sealed class CompactBlockProcessorException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
class DataDbMissing(path: String): CompactBlockProcessorException("No data db file found at path $path. Verify " +
"that the data DB has been initialized via `rustBackend.initDataDb(path)`")
@ -48,13 +58,9 @@ sealed class CompactBlockProcessorException(message: String, cause: Throwable? =
" can be fixed by re-importing the wallet.")
}
sealed class CompactBlockStreamException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object ConnectionClosed: CompactBlockStreamException("Cannot start stream when connection is closed.")
class FalseStart(cause: Throwable?): CompactBlockStreamException("Failed to start compact block stream due to " +
"$cause caused by ${cause?.cause}")
}
/**
* Exceptions related to the wallet's birthday.
*/
sealed class BirthdayException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object UninitializedBirthdayException : BirthdayException("Error the birthday cannot be" +
" accessed before it is initialized. Verify that the new, import or open functions" +
@ -76,6 +82,9 @@ sealed class BirthdayException(message: String, cause: Throwable? = null) : SdkE
)
}
/**
* Exceptions thrown by the initializer.
*/
sealed class InitializerException(message: String, cause: Throwable? = null) : SdkException(message, cause){
class FalseStart(cause: Throwable?) : InitializerException("Failed to initialize accounts due to: $cause", cause)
class AlreadyInitializedException(cause: Throwable, dbPath: String) : InitializerException("Failed to initialize the blocks table" +
@ -86,6 +95,9 @@ sealed class InitializerException(message: String, cause: Throwable? = null) :
" we can store data.")
}
/**
* Exceptions thrown while interacting with lightwalletd.
*/
sealed class LightwalletException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object InsecureConnection : LightwalletException("Error: attempted to connect to lightwalletd" +
" with an insecure connection! Plaintext connections are only allowed when the" +
@ -93,6 +105,9 @@ sealed class LightwalletException(message: String, cause: Throwable? = null) : S
" because this choice should be explicit.")
}
/**
* Potentially user-facing exceptions thrown while encoding transactions.
*/
sealed class TransactionEncoderException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
class FetchParamsException(message: String) : TransactionEncoderException("Failed to fetch params due to: $message")
object MissingParamsException : TransactionEncoderException(

View File

@ -9,6 +9,14 @@ import java.math.RoundingMode
import java.text.NumberFormat
import java.util.*
/*
* Convenience functions for converting currency values for display in user interfaces. The
* calculations done here are not intended for financial purposes, because all such transactions
* are done using Zatoshis in the Rust layer. Instead, these functions are focused on displaying
* accurately rounded values to the user.
*/
//TODO: provide a dynamic way to configure this globally for the SDK
// For now, just make these vars so at least they could be modified in one place
object Conversions {
@ -29,11 +37,15 @@ object Conversions {
/**
* Format a Zatoshi value into Zec with the given number of digits, represented as a string.
* Start with Zatoshi -> End with Zec.
* Format a Zatoshi value into ZEC with the given number of digits, represented as a string.
* Start with Zatoshi -> End with ZEC.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better than Usd.
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is
* better than USD.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return this Zatoshi value represented as ZEC, in a string with at least [minDecimals] and at
* most [maxDecimals]
*/
inline fun Long?.convertZatoshiToZecString(
maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits,
@ -43,11 +55,15 @@ inline fun Long?.convertZatoshiToZecString(
}
/**
* Format a Zec value into Zec with the given number of digits, represented as a string.
* Start with ZeC -> End with Zec.
* Format a ZEC value into ZEC with the given number of digits, represented as a string.
* Start with ZEC -> End with ZEC.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better when right.
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is
* better when right.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return this Double ZEC value represented as a string with at least [minDecimals] and at most
* [maxDecimals].
*/
inline fun Double?.toZecString(
maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits,
@ -57,11 +73,15 @@ inline fun Double?.toZecString(
}
/**
* Format a Zatoshi value into Zec with the given number of decimal places, represented as a string.
* Start with ZeC -> End with Zec.
* Format a Zatoshi value into ZEC with the given number of decimal places, represented as a string.
* Start with ZeC -> End with ZEC.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better than bread.
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is
* better than bread.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return this BigDecimal ZEC value represented as a string with at least [minDecimals] and at most
* [maxDecimals].
*/
inline fun BigDecimal?.toZecString(
maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits,
@ -71,10 +91,15 @@ inline fun BigDecimal?.toZecString(
}
/**
* Format a Usd value into Usd with the given number of digits, represented as a string.
* Format a USD value into USD with the given number of digits, represented as a string.
* Start with USD -> end with USD.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better than pennies
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because
* ZEC is glorious.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return this Double ZEC value represented as a string with at least [minDecimals] and at most
* [maxDecimals], which is 2 by default. Zero is always represented without any decimals.
*/
inline fun Double?.toUsdString(
maxDecimals: Int = USD_FORMATTER.maximumFractionDigits,
@ -88,9 +113,15 @@ inline fun Double?.toUsdString(
}
/**
* Format a Zatoshi value into Usd with the given number of decimal places, represented as a string.
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is glorious.
* Format a USD value into USD with the given number of decimal places, represented as a string.
* Start with USD -> end with USD.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is
* glorious.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return this BigDecimal USD value represented as a string with at least [minDecimals] and at most
* [maxDecimals], which is 2 by default.
*/
inline fun BigDecimal?.toUsdString(
maxDecimals: Int = USD_FORMATTER.maximumFractionDigits,
@ -100,9 +131,15 @@ inline fun BigDecimal?.toUsdString(
}
/**
* Create a number formatter for use with converting currency to strings. This probably isn't needed externally since
* the other formatting functions leverage this, instead. Leverages the default rounding mode for zec found in
* ZEC_FORMATTER.
* Create a number formatter for use with converting currency to strings. This probably isn't needed
* externally since the other formatting functions leverage this, instead. Leverages the default
* rounding mode for zec found in ZEC_FORMATTER.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is
* glorious.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return a currency formatter, appropriate for the default locale.
*/
inline fun currencyFormatter(maxDecimals: Int, minDecimals: Int): NumberFormat {
return NumberFormat.getInstance(Locale.getDefault()).apply {
@ -114,59 +151,108 @@ inline fun currencyFormatter(maxDecimals: Int, minDecimals: Int): NumberFormat {
}
/**
* Convert a Zatoshi value into Zec, right-padded to the given number of fraction digits, represented as a BigDecimal in
* order to preserve rounding that minimizes cumulative error when applied repeatedly over a sequence of calculations.
* Start with Zatoshi -> End with Zec.
* Convert a Zatoshi value into ZEC, right-padded to the given number of fraction digits,
* represented as a BigDecimal in order to preserve rounding that minimizes cumulative error when
* applied repeatedly over a sequence of calculations.
* Start with Zatoshi -> End with ZEC.
*
* @param scale the number of digits to the right of the decimal place. Right-padding will be added, if necessary.
* @param scale the number of digits to the right of the decimal place. Right-padding will be added,
* if necessary.
*
* @return this Long Zatoshi value represented as ZEC using a BigDecimal with the given scale,
* rounded accurately out to 128 digits.
*/
inline fun Long?.convertZatoshiToZec(scale: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal {
return BigDecimal(this ?: 0L, MathContext.DECIMAL128).divide(Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128).setScale(scale, ZEC_FORMATTER.roundingMode)
return BigDecimal(this ?: 0L, MathContext.DECIMAL128).divide(
Conversions.ONE_ZEC_IN_ZATOSHI,
MathContext.DECIMAL128
).setScale(scale, ZEC_FORMATTER.roundingMode)
}
/**
* Convert a Zec value into Zatoshi.
* Convert a ZEC value into Zatoshi.
* Start with ZEC -> End with Zatoshi.
*
* @return this ZEC value represented as Zatoshi, rounded accurately out to 128 digits, in order to
* minimize cumulative errors when applied repeatedly over a sequence of calculations.
*/
inline fun BigDecimal?.convertZecToZatoshi(): Long {
if (this == null) return 0L
if (this < BigDecimal.ZERO) throw IllegalArgumentException("Invalid ZEC value: $this. ZEC is represented by notes and cannot be negative")
if (this < BigDecimal.ZERO) {
throw IllegalArgumentException("Invalid ZEC value: $this. ZEC is represented by notes and" +
" cannot be negative")
}
return this.multiply(Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128).toLong()
}
/**
* Format a Double Zec value as a BigDecimal Zec value, right-padded to the given number of fraction digits.
* Start with Zec -> End with Zec.
* Format a Double ZEC value as a BigDecimal ZEC value, right-padded to the given number of fraction
* digits.
* Start with ZEC -> End with ZEC.
*
* @param decimals the scale to use for the resulting BigDecimal.
*
* @return this Double ZEC value converted into a BigDecimal, with the proper rounding mode for use
* with other formatting functions.
*/
inline fun Double?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal {
return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(decimals, ZEC_FORMATTER.roundingMode)
return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(
decimals,
ZEC_FORMATTER.roundingMode
)
}
/**
* Format a Double Zec value as a Long Zatoshi value, by first converting to Zec with the given
* Format a Double ZEC value as a Long Zatoshi value, by first converting to ZEC with the given
* precision.
* Start with Zec -> End with Zatoshi.
* Start with ZEC -> End with Zatoshi.
*
* @param decimals the scale to use for the intermediate BigDecimal.
*
* @return this Double ZEC value converted into Zatoshi, with proper rounding and precision by
* leveraging an intermediate BigDecimal object.
*/
inline fun Double?.convertZecToZatoshi(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): Long {
return this.toZec(decimals).convertZecToZatoshi()
}
/**
* Format a BigDecimal Zec value as a BigDecimal Zec value, right-padded to the given number of fraction digits.
* Start with Zec -> End with Zec.
* Format a BigDecimal ZEC value as a BigDecimal ZEC value, right-padded to the given number of
* fraction digits.
* Start with ZEC -> End with ZEC.
*
* @param decimals the scale to use for the resulting BigDecimal.
*
* @return this BigDecimal ZEC adjusted to the default scale and rounding mode.
*/
inline fun BigDecimal?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal {
return (this ?: BigDecimal.ZERO).setScale(decimals, ZEC_FORMATTER.roundingMode)
}
/**
* Format a Double Usd value as a BigDecimal Usd value, right-padded to the given number of fraction digits.
* Format a Double USD value as a BigDecimal USD value, right-padded to the given number of fraction
* digits.
* Start with USD -> End with USD.
*
* @param decimals the scale to use for the resulting BigDecimal.
*
* @return this Double USD value converted into a BigDecimal, with proper rounding and precision.
*/
inline fun Double?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits): BigDecimal {
return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(decimals, USD_FORMATTER.roundingMode)
return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(
decimals,
USD_FORMATTER.roundingMode
)
}
/**
* Format a BigDecimal Usd value as a BigDecimal Usd value, right-padded to the given number of fraction digits.
* Format a BigDecimal USD value as a BigDecimal USD value, right-padded to the given number of
* fraction digits.
* Start with USD -> End with USD.
*
* @param decimals the scale to use for the resulting BigDecimal.
*
* @return this BigDecimal USD value converted into USD, with proper rounding and precision.
*/
inline fun BigDecimal?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits): BigDecimal {
return (this ?: BigDecimal.ZERO).setScale(decimals, USD_FORMATTER.roundingMode)
@ -174,30 +260,49 @@ inline fun BigDecimal?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits
/**
* Convert this ZEC value to USD, using the given price per ZEC.
* Start with ZEC -> End with USD.
*
* @param zecPrice the current price of ZEC represented as USD per ZEC
*
* @return this BigDecimal USD value converted into USD, with proper rounding and precision.
*/
inline fun BigDecimal?.convertZecToUsd(zecPrice: BigDecimal): BigDecimal {
if(this == null) return BigDecimal.ZERO
if(this < BigDecimal.ZERO) throw IllegalArgumentException("Invalid ZEC value: ${zecPrice.toDouble()}. ZEC is represented by notes and cannot be negative")
if(this < BigDecimal.ZERO) {
throw IllegalArgumentException("Invalid ZEC value: ${zecPrice.toDouble()}. ZEC is" +
" represented by notes and cannot be negative")
}
return this.multiply(zecPrice, MathContext.DECIMAL128)
}
/**
* Convert this USD value to ZEC, using the given price per ZEC.
* Start with USD -> End with ZEC.
*
* @param zecPrice the current price of ZEC represented as USD per ZEC
* @param zecPrice the current price of ZEC represented as USD per ZEC.
*
* @return this BigDecimal USD value converted into ZEC, with proper rounding and precision.
*/
inline fun BigDecimal?.convertUsdToZec(zecPrice: BigDecimal): BigDecimal {
if(this == null) return BigDecimal.ZERO
if(this < BigDecimal.ZERO) throw IllegalArgumentException("Invalid USD value: ${zecPrice.toDouble()}. Converting this would result in negative ZEC and ZEC is represented by notes and cannot be negative")
if(this < BigDecimal.ZERO) {
throw IllegalArgumentException("Invalid USD value: ${zecPrice.toDouble()}. Converting" +
" this would result in negative ZEC and ZEC is represented by notes and cannot be" +
" negative")
}
return this.divide(zecPrice, MathContext.DECIMAL128)
}
/**
* Convert this value from one currency to the other, based on given price and whether this value is USD.
* Convert this value from one currency to the other, based on given price and whether this value is
* USD.
* If starting with USD -> End with ZEC.
* If starting with ZEC -> End with USD.
*
* @param isUsd whether this value represents USD or not (ZEC)
* @param isUSD whether this value represents USD or not (ZEC)
*
* @return this BigDecimal value converted from one currency into the other, based on the given
* price.
*/
inline fun BigDecimal.convertCurrency(zecPrice: BigDecimal, isUsd: Boolean): BigDecimal {
return if (isUsd) {
@ -210,7 +315,7 @@ inline fun BigDecimal.convertCurrency(zecPrice: BigDecimal, isUsd: Boolean): Big
/**
* Parse this string into a BigDecimal, ignoring all non numeric characters.
*
* @return null when parsing fails
* @return this string as a BigDecimal or null when parsing fails.
*/
inline fun String?.safelyConvertToBigDecimal(): BigDecimal? {
if (this.isNullOrEmpty()) return BigDecimal.ZERO
@ -223,8 +328,34 @@ inline fun String?.safelyConvertToBigDecimal(): BigDecimal? {
}
}
inline fun String.toAbbreviatedAddress(startLength: Int = 8, endLength: Int = 8) = if (length > startLength + endLength) "${take(startLength)}${takeLast(endLength)}" else this
/**
* Abbreviates this string which is assumed to be an address.
*
* @param startLength the number of characters to show before the elipsis.
* @param endLength the number of characters to show after the elipsis.
*
* @return the abbreviated string unless the string is too short, in which case the original string
* is returned.
*/
inline fun String.toAbbreviatedAddress(startLength: Int = 8, endLength: Int = 8) =
if (length > startLength + endLength) "${take(startLength)}${takeLast(endLength)}" else this
internal inline fun String.masked(): String = if (startsWith("ztest") || startsWith("zs")) "****${takeLast(4)}" else "***masked***"
/**
* Masks the current string for use in logs. If this string appears to be an address, the last
* [addressCharsToShow] characters will be visible.
*
* @param addressCharsToShow the number of chars to show at the end, if this value appears to be an
* address.
*
* @return the masked version of this string, typically for use in logs.
*/
internal inline fun String.masked(addressCharsToShow: Int = 4): String =
if (startsWith("ztest") || startsWith("zs")) "****${takeLast(addressCharsToShow)}"
else "***masked***"
/**
* Convenience function that returns true when this string starts with 'z'.
*
* @return true when this function starts with 'z' rather than 't'.
*/
inline fun String?.isShielded() = this != null && startsWith('z')

View File

@ -38,6 +38,10 @@ fun <T> Flow<T>.collectWith(scope: CoroutineScope, block: (T) -> Unit) {
}
}
/**
* Utility for performing the given action on the first emission of a flow and running that action
* in the given scope.
*/
fun <T, S> Flow<T>.onFirstWith(scope: CoroutineScope, block: suspend (T) -> S) {
scope.launch {
onEach {
@ -46,6 +50,9 @@ fun <T, S> Flow<T>.onFirstWith(scope: CoroutineScope, block: suspend (T) -> S) {
}
}
/**
* Utility for performing the given action on the first emission of a flow.
*/
suspend fun <T, S> Flow<T>.onFirst(block: suspend (T) -> S) {
onEach {
block(it)

View File

@ -1,39 +0,0 @@
package cash.z.wallet.sdk.ext
import android.content.Context
import android.content.SharedPreferences
import cash.z.wallet.sdk.BuildConfig
fun SharedPrefs(context: Context, name: String = "prefs"): SharedPreferences {
val fileName = "${BuildConfig.FLAVOR}.${BuildConfig.BUILD_TYPE}.$name".toLowerCase()
return context.getSharedPreferences(fileName, Context.MODE_PRIVATE)!!
}
inline fun SharedPreferences.edit(block: (SharedPreferences.Editor) -> Unit) {
edit().run {
block(this)
apply()
}
}
operator fun SharedPreferences.set(key: String, value: Any?) {
when (value) {
is String? -> edit { it.putString(key, value) }
is Int -> edit { it.putInt(key, value) }
is Boolean -> edit { it.putBoolean(key, value) }
is Float -> edit { it.putFloat(key, value) }
is Long -> edit { it.putLong(key, value) }
else -> throw UnsupportedOperationException("Not yet implemented")
}
}
inline operator fun <reified T : Any> SharedPreferences.get(key: String, defaultValue: T? = null): T? {
return when (T::class) {
String::class -> getString(key, defaultValue as? String) as T?
Int::class -> getInt(key, defaultValue as? Int ?: -1) as T?
Boolean::class -> getBoolean(key, defaultValue as? Boolean ?: false) as T?
Float::class -> getFloat(key, defaultValue as? Float ?: -1f) as T?
Long::class -> getLong(key, defaultValue as? Long ?: -1) as T?
else -> throw UnsupportedOperationException("Not yet implemented")
}
}

View File

@ -10,7 +10,15 @@ internal typealias Leaf = String
* A tiny log.
*/
interface Twig {
/**
* Log the message. Simple.
*/
fun twig(logMessage: String = "")
/**
* Bundles twigs together
*/
operator fun plus(twig: Twig): Twig {
// if the other twig is a composite twig, let it handle the addition
return if(twig is CompositeTwig) twig.plus(this) else CompositeTwig(mutableListOf(this, twig))
@ -74,6 +82,10 @@ inline fun <R> twigTask(logMessage: String, block: () -> R): R = Bush.trunk.twig
* A tiny log that does nothing. No one hears this twig fall in the woods.
*/
class SilentTwig : Twig {
/**
* Shh.
*/
override fun twig(logMessage: String) {
// shh
}
@ -89,6 +101,10 @@ open class TroubleshootingTwig(
val formatter: (String) -> String = spiffy(5),
val printer: (String) -> Any = System.err::println
) : Twig {
/**
* Actually print and format the log message, unlike the SilentTwig, which does nothing.
*/
override fun twig(logMessage: String) {
printer(formatter(logMessage))
}

View File

@ -58,6 +58,15 @@ inline fun retrySimple(retries: Int = 2, sleepTime: Long = 20L, block: (Int) ->
}
}
/**
* Execute the given block and if it fails, retry with an exponential backoff.
*
* @param onErrorListener a callback that gets the first shot at processing any error and can veto
* the retry behavior by returning false.
* @param initialDelayMillis the initial delay before retrying.
* @param maxDelayMillis the maximum delay between retrys.
* @param block the logic to run once and then run again if it fails.
*/
suspend inline fun retryWithBackoff(noinline onErrorListener: ((Throwable) -> Boolean)? = null, initialDelayMillis: Long = 1000L, maxDelayMillis: Long = MAX_BACKOFF_INTERVAL, block: () -> Unit) {
var sequence = 0 // count up to the max and then reset to half. So that we don't repeat the max but we also don't repeat too much.
while (true) {
@ -83,6 +92,11 @@ suspend inline fun retryWithBackoff(noinline onErrorListener: ((Throwable) -> Bo
}
}
/**
* Return true if the given database already exists.
*
* @return true when the given database exists in the given context.
*/
internal fun dbExists(appContext: Context, dbFileName: String): Boolean {
return File(appContext.getDatabasePath(dbFileName).absolutePath).exists()
}

View File

@ -3,6 +3,8 @@ package cash.z.wallet.sdk.jni
/**
* Contract defining the exposed capabilities of the Rust backend.
* This is what welds the SDK to the Rust layer.
* It is not documented because it is not intended to be used, directly.
* Instead, use the synchronizer or one of its subcomponents.
*/
interface RustBackendWelding {

View File

@ -17,10 +17,10 @@ import java.util.concurrent.TimeUnit
/**
* Implementation of LightwalletService using gRPC for requests to lightwalletd.
*
* @param channel the channel to use for communicating with the lightwalletd server.
* @param singleRequestTimeoutSec the timeout to use for non-streaming requests. When a new stub is
* @property channel the channel to use for communicating with the lightwalletd server.
* @property singleRequestTimeoutSec the timeout to use for non-streaming requests. When a new stub is
* created, it will use a deadline that is after the given duration from now.
* @param streamingRequestTimeoutSec the timeout to use for streaming requests. When a new stub is
* @property streamingRequestTimeoutSec the timeout to use for streaming requests. When a new stub is
* created for streaming requests, it will use a deadline that is after the given duration from now.
*/
class LightWalletGrpcService private constructor(
@ -29,6 +29,16 @@ class LightWalletGrpcService private constructor(
private val streamingRequestTimeoutSec: Long = 90L
) : LightWalletService {
/**
* Construct an instance that corresponds to the given host and port.
*
* @param appContext the application context used to check whether TLS is required by this build
* flavor.
* @param host the host of the server to use.
* @param port the port of the server to use.
* @param usePlaintext whether to use TLS or plaintext for requests. Plaintext is dangerous so
* it requires jumping through a few more hoops.
*/
constructor(
appContext: Context,
host: String,
@ -38,12 +48,6 @@ class LightWalletGrpcService private constructor(
/* LightWalletService implementation */
/**
* Blocking call to download all blocks in the given range.
*
* @param heightRange the inclusive range of block heights to download.
* @return a list of compact blocks for the given range
*/
override fun getBlockRange(heightRange: IntRange): List<CompactFormats.CompactBlock> {
channel.resetConnectBackoff()
return channel.createStub(streamingRequestTimeoutSec).getBlockRange(heightRange.toBlockRange()).toList()
@ -89,6 +93,11 @@ class LightWalletGrpcService private constructor(
}
companion object {
/**
* 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,
host: String,

View File

@ -13,16 +13,23 @@ interface LightWalletService {
*
* @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: IntRange): List<CompactFormats.CompactBlock>
/**
* Return the latest block height known to the service.
*
* @return the latest block height known to the service.
*/
fun getLatestBlockHeight(): Int
/**
* Submit a raw transaction.
*
* @return the response from the server.
*/
fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse

View File

@ -79,6 +79,9 @@ open class PagedTransactionRepository(
transactions.findMinedHeight(rawTransactionId)
}
/**
* Close the underlying database.
*/
fun close() {
derivedDataDb.close()
}
@ -109,7 +112,7 @@ open class PagedTransactionRepository(
// }
// }
val MIGRATION_4_3 = object : Migration(4, 3) {
private val MIGRATION_4_3 = object : Migration(4, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("PRAGMA foreign_keys = OFF;")
database.execSQL(

View File

@ -5,7 +5,10 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import cash.z.wallet.sdk.db.PendingTransactionDao
import cash.z.wallet.sdk.db.PendingTransactionDb
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.entity.PendingTransaction
import cash.z.wallet.sdk.entity.PendingTransactionEntity
import cash.z.wallet.sdk.entity.isCancelled
import cash.z.wallet.sdk.entity.isSubmitted
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.service.LightWalletService
import kotlinx.coroutines.Dispatchers
@ -13,13 +16,19 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.lang.IllegalStateException
import kotlin.math.max
/**
* Facilitates persistent attempts to ensure a transaction occurs.
* Facilitates persistent attempts to ensure that an outbound transaction is completed.
*
* @param db the database where the wallet can freely write information related to pending
* transactions. This database effectively serves as the mempool for transactions created by this
* wallet.
* @property encoder responsible for encoding a transaction by taking all the inputs and returning
* an [cash.z.wallet.sdk.entity.EncodedTransaction] object containing the raw bytes and transaction
* id.
* @property service the lightwallet service used to submit transactions.
*/
// TODO: consider having the manager register the fail listeners rather than having that responsibility spread elsewhere (synchronizer and the broom)
class PersistentTransactionManager(
db: PendingTransactionDb,
private val encoder: TransactionEncoder,
@ -52,10 +61,11 @@ class PersistentTransactionManager(
service
)
/**
* Initialize a [PendingTransaction] and then insert it in the database for monitoring and
* follow-up.
*/
//
// OutboundTransactionManager implementation
//
override suspend fun initSpend(
zatoshiValue: Long,
toAddress: String,
@ -79,7 +89,8 @@ class PersistentTransactionManager(
twig("successfully created TX in DB")
}
} catch (t: Throwable) {
twig("Unknown error while attempting to create pending transaction: ${t.message} caused by: ${t.cause}")
twig("Unknown error while attempting to create pending transaction: ${t.message}" +
" caused by: ${t.cause}")
}
tx
@ -92,21 +103,11 @@ class PersistentTransactionManager(
}
}
/**
* Remove a transaction and pretend it never existed.
*/
suspend fun abortTransaction(existingTransaction: PendingTransaction) {
pendingTransactionDao {
delete(existingTransaction as PendingTransactionEntity)
}
}
override suspend fun encode(
spendingKey: String,
pendingTx: PendingTransaction
): PendingTransaction = withContext(Dispatchers.IO) {
twig("managing the creation of a transaction")
//var tx = transaction.copy(expiryHeight = if (currentHeight == -1) -1 else currentHeight + EXPIRY_OFFSET)
var tx = pendingTx as PendingTransactionEntity
try {
twig("beginning to encode transaction with : $encoder")
@ -123,7 +124,7 @@ class PersistentTransactionManager(
val message = "failed to encode transaction due to : ${t.message} caused by: ${t.cause}"
twig(message)
message
tx = tx.copy(errorMessage = message, errorCode = 2000) //TODO: find a place for these error codes
tx = tx.copy(errorMessage = message, errorCode = ERROR_ENCODING)
} finally {
tx = tx.copy(encodeAttempts = max(1, tx.encodeAttempts + 1))
}
@ -134,15 +135,19 @@ class PersistentTransactionManager(
override suspend fun submit(pendingTx: PendingTransaction): PendingTransaction = withContext(Dispatchers.IO) {
// reload the tx to check for cancellation
var storedTx = pendingTransactionDao { findById(pendingTx.id) } ?: throw IllegalStateException("Error while submitting transaction. No pending transaction found that matches the one being submitted. Verify that the transaction still exists among the set of pending transactions.")
var storedTx = pendingTransactionDao { findById(pendingTx.id) }
?: throw IllegalStateException("Error while submitting transaction. No pending" +
" transaction found that matches the one being submitted. Verify that the" +
" transaction still exists among the set of pending transactions.")
var tx = storedTx
try {
// do nothing when cancelled
if (!tx.isCancelled()) {
twig("submitting transaction to lightwalletd - memo: ${tx.memo} amount: ${tx.value}")
twig("submitting transaction with memo: ${tx.memo} amount: ${tx.value}")
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}")
twig("${if (error) "FAILURE! " else "SUCCESS!"} submit transaction completed with" +
" response: ${response.errorCode}: ${response.errorMessage}")
tx = tx.copy(
errorMessage = if (error) response.errorMessage else null,
errorCode = response.errorCode,
@ -157,7 +162,11 @@ class PersistentTransactionManager(
val message =
"Unknown error while submitting transaction: ${t.message} caused by: ${t.cause}"
twig(message)
tx = tx.copy(errorMessage = t.message, errorCode = 3000, submitAttempts = max(1, tx.submitAttempts + 1)) //TODO: find a place for these error codes
tx = tx.copy(
errorMessage = t.message,
errorCode = ERROR_SUBMITTING,
submitAttempts = max(1, tx.submitAttempts + 1)
)
safeUpdate(tx)
}
@ -188,18 +197,33 @@ class PersistentTransactionManager(
override fun getAll() = _dao.getAll()
//
// Helper functions
//
/**
* Remove a transaction and pretend it never existed.
*/
suspend fun abortTransaction(existingTransaction: PendingTransaction) {
pendingTransactionDao {
delete(existingTransaction as PendingTransactionEntity)
}
}
/**
* Updating the pending transaction is often done at the end of a function but still should
* happen within a try/catch block, surrounded by logging. So this helps with that.
*/
private suspend fun safeUpdate(tx: PendingTransactionEntity): PendingTransaction {
return try {
twig("updating tx into DB: $tx")
twig("updating tx in DB: $tx")
pendingTransactionDao { update(tx) }
twig("successfully updated TX into DB")
twig("successfully updated TX in DB")
tx
} catch (t: Throwable) {
twig("Unknown error while attempting to update pending transaction: ${t.message} caused by: ${t.cause}")
twig("Unknown error while attempting to update pending transaction: ${t.message}" +
" caused by: ${t.cause}")
tx
}
}
@ -209,5 +233,12 @@ class PersistentTransactionManager(
_dao.block()
}
}
companion object {
/** Error code for an error while encoding a transaction */
const val ERROR_ENCODING = 2000
/** Error code for an error while submitting a transaction */
const val ERROR_SUBMITTING = 3000
}
}

View File

@ -1,295 +0,0 @@
package cash.z.wallet.sdk.transaction
//import cash.z.wallet.sdk.transaction.PersistentTransactionSender.ChangeType.*
//import cash.z.wallet.sdk.transaction.TransactionUpdateRequest.RefreshSentTx
//import cash.z.wallet.sdk.transaction.TransactionUpdateRequest.SubmitPendingTx
//import cash.z.wallet.sdk.entity.PendingTransaction
//import cash.z.wallet.sdk.entity.isMined
//import cash.z.wallet.sdk.entity.isPending
//import cash.z.wallet.sdk.ext.retryWithBackoff
//import cash.z.wallet.sdk.ext.twig
//import cash.z.wallet.sdk.service.LightWalletService
//import kotlinx.coroutines.*
//import kotlinx.coroutines.Dispatchers.IO
//import kotlinx.coroutines.channels.SendChannel
//import kotlinx.coroutines.channels.actor
//import kotlin.math.min
//
//
///**
// * Monitors pending transactions and sends or retries them, when appropriate.
// */
//class PersistentTransactionSender (
// private val manager: TransactionManager,
// private val service: LightWalletService,
// private val ledger: TransactionRepository
//) : TransactionSender {
//
// private lateinit var channel: SendChannel<TransactionUpdateRequest>
// private var monitoringJob: Job? = null
// private val initialMonitorDelay = 45_000L
// private var listenerChannel: SendChannel<List<PendingTransaction>>? = null
// override var onSubmissionError: ((Throwable) -> Boolean)? = null
// private var updateResult: CompletableDeferred<ChangeType>? = null
// var lastChangeDetected: ChangeType = NoChange(0)
// set(value) {
// field = value
// val details = when(value) {
// is SizeChange -> " from ${value.oldSize} to ${value.newSize}"
// is Modified -> " The culprit: ${value.tx}"
// is NoChange -> " for the ${value.count.asOrdinal()} time"
// else -> ""
// }
// twig("Checking pending tx detected: ${value.description}$details")
// updateResult?.complete(field)
// }
//
// fun CoroutineScope.requestUpdate(triggerSend: Boolean) = launch {
// if (!channel.isClosedForSend) {
// channel.send(if (triggerSend) SubmitPendingTx else RefreshSentTx)
// } else {
// twig("request ignored because the channel is closed for send!!!")
// }
// }
//
// /**
// * Start an actor that listens for signals about what to do with transactions. This actor's lifespan is within the
// * provided [scope] and it will live until the scope is cancelled.
// */
// private fun CoroutineScope.startActor() = actor<TransactionUpdateRequest> {
// var pendingTransactionDao = 0 // actor state:
// for (msg in channel) { // iterate over incoming messages
// when (msg) {
// is SubmitPendingTx -> updatePendingTransactions()
// is RefreshSentTx -> refreshSentTransactions()
// }
// }
// }
//
// private fun CoroutineScope.startMonitor() = launch {
// delay(5000) // todo see if we need a formal initial delay
// while (!channel.isClosedForSend && isActive) {
// // TODO: consider refactoring this since we actually want to wait on the return value of requestUpdate
// updateResult = CompletableDeferred()
// requestUpdate(true)
// updateResult?.await()
// delay(calculateDelay())
// }
// twig("TransactionMonitor stopping!")
// }
//
// private fun calculateDelay(): Long {
// // if we're actively waiting on results, then poll faster
// val delay = when (lastChangeDetected) {
// FirstChange -> initialMonitorDelay / 4
// is NothingPending, is NoChange -> {
// // simple linear offset when there has been no change
// val count = (lastChangeDetected as? BackoffEnabled)?.count ?: 0
// val offset = initialMonitorDelay / 5L * count
// if (previousSentTxs?.isNotEmpty() == true) {
// initialMonitorDelay / 4
// } else {
// initialMonitorDelay
// } + offset
// }
// is SizeChange -> initialMonitorDelay / 4
// is Modified -> initialMonitorDelay / 4
// }
// return min(delay, initialMonitorDelay * 8).also {
// twig("Checking for pending tx changes again in ${it/1000L}s")
// }
// }
//
// override fun start(scope: CoroutineScope) {
// twig("TransactionMonitor starting!")
// channel = scope.startActor()
// monitoringJob?.cancel()
// monitoringJob = scope.startMonitor()
// }
//
// override fun stop() {
// channel.close()
// monitoringJob?.cancel()?.also { monitoringJob = null }
// manager.stop()
// }
//
// override fun notifyOnChange(channel: SendChannel<List<PendingTransaction>>) {
// if (channel != null) twig("warning: listener channel was not null but it probably should have been. Something else was listening with $channel!")
// listenerChannel = channel
// }
//
// override suspend fun initTransaction(
// zatoshiValue: Long,
// toAddress: String,
// memo: String,
// fromAccountIndex: Int
// ) = withContext(IO) {
// manager.initTransaction(
// zatoshiValue,
// toAddress,
// memo,
// fromAccountIndex
// )
// }
// /**
// * Generates newly persisted information about a transaction so that other processes can send.
// */
//// override suspend fun sendToAddress(
//// encoder: TransactionEncoder,
//// zatoshi: Long,
//// toAddress: String,
//// memo: String,
//// fromAccountId: Int
//// ): PendingTransaction = withContext(IO) {
//// val currentHeight = service.safeLatestBlockHeight()
//// (manager as PersistentTransactionManager).manageCreation(encoder, zatoshi, toAddress, memo, currentHeight).also {
//// requestUpdate(true)
//// }
//// }
//
//// override suspend fun prepareTransaction(
//// zatoshiValue: Long,
//// address: String,
//// memo: String
//// ): PendingTransaction? = withContext(IO) {
//// (manager as PersistentTransactionManager).initPlaceholder(zatoshiValue, address, memo).also {
//// // update UI to show what we've just created. No need to submit, it has no raw data yet!
//// requestUpdate(false)
//// }
//// }
//
//// override suspend fun sendPreparedTransaction(
//// encoder: TransactionEncoder,
//// tx: PendingTransaction
//// ): PendingTransaction = withContext(IO) {
//// val currentHeight = service.safeLatestBlockHeight()
//// (manager as PersistentTransactionManager).manageCreation(encoder, tx, currentHeight).also {
//// // submit what we've just created
//// requestUpdate(true)
//// }
//// }
//
// override suspend fun cleanupPreparedTransaction(tx: PendingTransaction) {
// if (tx.raw.isEmpty()) {
// (manager as PersistentTransactionManager).abortTransaction(tx)
// }
// }
//
// // TODO: get this from the channel instead
// var previousSentTxs: List<PendingTransaction>? = null
//
// private suspend fun notifyIfChanged(currentSentTxs: List<PendingTransaction>) = withContext(IO) {
// if (hasChanged(previousSentTxs, currentSentTxs) && listenerChannel?.isClosedForSend != true) {
// twig("START notifying listenerChannel of changed txs")
// listenerChannel?.send(currentSentTxs)
// twig("DONE notifying listenerChannel of changed txs")
// previousSentTxs = currentSentTxs
// } else {
// twig("notifyIfChanged: did nothing because ${if(listenerChannel?.isClosedForSend == true) "the channel is closed." else "nothing changed."}")
// }
// }
//
// override suspend fun cancel(existingTransaction: PendingTransaction) = withContext(IO) {
// (manager as PersistentTransactionManager).abortTransaction(existingTransaction). also {
// requestUpdate(false)
// }
// }
//
// private fun hasChanged(
// previousSents: List<PendingTransaction>?,
// currentSents: List<PendingTransaction>
// ): Boolean {
// // shortcuts first
// if (currentSents.isEmpty() && previousSents.isNullOrEmpty()) return false.also {
// val count = if (lastChangeDetected is BackoffEnabled) ((lastChangeDetected as? BackoffEnabled)?.count ?: 0) + 1 else 1
// lastChangeDetected = NothingPending(count)
// }
// if (previousSents == null) return true.also { lastChangeDetected = FirstChange }
// if (previousSents.size != currentSents.size) return true.also { lastChangeDetected = SizeChange(previousSentTxs?.size ?: -1, currentSents.size) }
// for (tx in currentSents) {
// // note: implicit .equals check inside `contains` will also detect modifications
// if (!previousSents.contains(tx)) return true.also { lastChangeDetected = Modified(tx) }
// }
// return false.also {
// val count = if (lastChangeDetected is BackoffEnabled) ((lastChangeDetected as? BackoffEnabled)?.count ?: 0) + 1 else 1
// lastChangeDetected = NoChange(count)
// }
// }
//
// sealed class ChangeType(val description: String) {
// object FirstChange : ChangeType("This is the first time we've seen a change!")
// data class NothingPending(override val count: Int) : ChangeType("Nothing happened yet!"), BackoffEnabled
// data class NoChange(override val count: Int) : ChangeType("No changes"), BackoffEnabled
// class SizeChange(val oldSize: Int, val newSize: Int) : ChangeType("The total number of pending transactions has changed")
// class Modified(val tx: PendingTransaction) : ChangeType("At least one transaction has been modified")
// }
// interface BackoffEnabled {
// val count: Int
// }
//
// /**
// * Check on all sent transactions and if they've changed, notify listeners. This method can be called proactively
// * when anything interesting has occurred with a transaction (via [requestUpdate]).
// */
// private suspend fun refreshSentTransactions(): List<PendingTransaction> = withContext(IO) {
// val allSentTransactions = (manager as PersistentTransactionManager).getAll() // TODO: make this crash and catch error gracefully
// notifyIfChanged(allSentTransactions)
// allSentTransactions
// }
//
// /**
// * Submit all pending transactions that have not expired.
// */
// private suspend fun updatePendingTransactions() = withContext(IO) {
// try {
// val allTransactions = refreshSentTransactions()
// var pendingCount = 0
// val currentHeight = service.safeLatestBlockHeight()
// allTransactions.filter { !it.isMined() }.forEach { tx ->
// if (tx.isPending(currentHeight)) {
// pendingCount++
// retryWithBackoff(onSubmissionError, 1000L, 60_000L) {
// manager.manageSubmission(service, tx)
// }
// } else {
// tx.rawTransactionId?.let {
// ledger.findTransactionByRawId(tx.rawTransactionId)
// }?.let {
// if (it.minedHeight != null) {
// twig("matching mined transaction found! $tx")
// (manager as PersistentTransactionManager).manageMined(tx, it)
// refreshSentTransactions()
// }
// }
// }
// }
// twig("given current height $currentHeight, we found $pendingCount pending txs to submit")
// } catch (t: Throwable) {
// t.printStackTrace()
// twig("Error during updatePendingTransactions: $t caused by ${t.cause}")
// }
// }
//}
//
//private fun Int.asOrdinal(): String {
// return "$this" + if (this % 100 in 11..13) "th" else when(this % 10) {
// 1 -> "st"
// 2 -> "nd"
// 3 -> "rd"
// else -> "th"
// }
//}
//
//private fun LightWalletService.safeLatestBlockHeight(): Int {
// return try {
// getLatestBlockHeight()
// } catch (t: Throwable) {
// twig("Warning: LightWalletService failed to return the latest height and we are returning -1 instead.")
// -1
// }
//}
//
//sealed class TransactionUpdateRequest {
// object SubmitPendingTx : TransactionUpdateRequest()
// object RefreshSentTx : TransactionUpdateRequest()
//}

View File

@ -4,7 +4,17 @@ import cash.z.wallet.sdk.entity.EncodedTransaction
interface TransactionEncoder {
/**
* Creates a signed transaction
* Creates a transaction, throwing an exception whenever things are missing. When the provided
* wallet implementation doesn't throw an exception, we wrap the issue into a descriptive
* exception ourselves (rather than using double-bangs for things).
*
* @param spendingKey the key associated with the notes that will be spent.
* @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 fromAccountIndex the optional account id to use. By default, the 1st account is used.
*
* @return the successfully encoded transaction or an exception
*/
suspend fun createTransaction(
spendingKey: String,
@ -14,6 +24,23 @@ interface TransactionEncoder {
fromAccountIndex: Int = 0
): EncodedTransaction
/**
* Utility function to help with validation. This is not called during [createTransaction]
* because this class asserts that all validation is done externally by the UI, for now.
*
* @param address the address to validate
*
* @return true when the given address is a valid z-addr
*/
suspend fun isValidShieldedAddress(address: String): Boolean
/**
* Utility function to help with validation. This is not called during [createTransaction]
* because this class asserts that all validation is done externally by the UI, for now.
*
* @param address the address to validate
*
* @return true when the given address is a valid t-addr
*/
suspend fun isValidTransparentAddress(address: String): Boolean
}

View File

@ -9,29 +9,110 @@ import kotlinx.coroutines.flow.Flow
* transactions through to completion.
*/
interface OutboundTransactionManager {
/**
* Initialize a spend with the main purpose of creating an idea to use for tracking it until
* completion.
*
* @param zatoshi the amount to spend.
* @param toAddress the address to which funds will be sent.
* @param memo the optionally blank memo associated with this transaction.
* @param fromAccountIndex the account from which to spend funds.
*
* @return the associated pending transaction whose ID can be used to monitor for changes.
*/
suspend fun initSpend(
zatoshi: Long,
toAddress: String,
memo: String,
fromAccountIndex: Int
): PendingTransaction
/**
* Encode the pending transaction using the given spending key. This is a local operation that
* produces a raw transaction to submit to lightwalletd.
*
* @param spendingKey the spendingKey to use for constructing the transaction.
* @param pendingTx the transaction information created by [initSpend] that will be used to
* construct a transaction.
*
* @return the resulting pending transaction whose ID can be used to monitor for changes.
*/
suspend fun encode(spendingKey: String, pendingTx: PendingTransaction): PendingTransaction
/**
* Submits the transaction represented by [pendingTx] to lightwalletd to broadcast to the
* network and, hopefully, include in the next block.
*
* @param pendingTx the transaction information containing the raw bytes that will be submitted
* to lightwalletd.
*
* @return the resulting pending transaction whose ID can be used to monitor for changes.
*/
suspend fun submit(pendingTx: PendingTransaction): PendingTransaction
/**
* Given a transaction and the height at which it was mined, update the transaction to indicate
* that it was mined.
*
* @param pendingTx the pending transaction that has been mineed.
* @param minedHeight the height at which the given transaction was mined, according to the data
* that has been processed from the blockchain.
*/
suspend fun applyMinedHeight(pendingTx: PendingTransaction, minedHeight: Int)
/**
* Generate a flow of information about the given id where a new pending transaction is emitted
* every time its state changes.
*
* @param id the id to monitor.
*
* @return a flow of pending transactions that are emitted anytime the transaction associated
* withh the given id changes.
*/
suspend fun monitorById(id: Long): Flow<PendingTransaction>
/**
* Return true when the given address is a valid t-addr.
*
* @param address the address to validate.
*
* @return true when the given address is a valid t-addr.
*/
suspend fun isValidShieldedAddress(address: String): Boolean
/**
* Return true when the given address is a valid z-addr.
*
* @param address the address to validate.
*
* @return true when the given address is a valid z-addr.
*/
suspend fun isValidTransparentAddress(address: String): Boolean
/**
* Attempt to cancel a transaction.
*
* @param pendingTx the transaction matching the ID of the transaction to cancel.
*
* @return true when the transaction was able to be cancelled.
*/
suspend fun cancel(pendingTx: PendingTransaction): Boolean
/**
* Get all pending transactions known to this wallet as a flow that is updated anytime the list
* changes.
*
* @return a flow of all pending transactions known to this wallet.
*/
fun getAll(): Flow<List<PendingTransaction>>
}
/**
* Interface for transaction errors.
*/
interface TransactionError {
/**
* The message associated with this error.
*/
val message: String
}

View File

@ -4,10 +4,42 @@ 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 {
/**
* The last height scanned by this repository.
*
* @return the last height scanned by this repository.
*/
fun lastScannedHeight(): Int
/**
* Returns true when this repository has been initialized and seeded with the initial checkpoint.
*
* @return true when this repository has been initialized and seeded with the initial checkpoint.
*/
fun isInitialized(): Boolean
/**
* Find the encoded transaction associated with the given id.
*
* @param txId the id of the transaction to find.
*
* @return the transaction or null when it cannot be found.
*/
suspend fun findEncodedTransactionById(txId: Long): EncodedTransaction?
/**
* Find the mined height that matches the given raw tx_id in bytes. This is useful for matching
* a pending transaction with one that we've decrypted from the blockchain.
*
* @param rawTransactionId the id of the transaction to find.
*
* @return the mined height of the given transaction, if it is known to this wallet.
*/
suspend fun findMinedHeight(rawTransactionId: ByteArray): Int?
/**
@ -20,7 +52,10 @@ interface TransactionRepository {
// Transactions
//
/** A flow of all the inbound confirmed transactions */
val receivedTransactions: Flow<PagedList<ConfirmedTransaction>>
/** A flow of all the outbound confirmed transactions */
val sentTransactions: Flow<PagedList<ConfirmedTransaction>>
/** A flow of all the inbound and outbound confirmed transactions */
val allTransactions: Flow<PagedList<ConfirmedTransaction>>
}

View File

@ -1,23 +0,0 @@
package cash.z.wallet.sdk.transaction
import cash.z.wallet.sdk.entity.PendingTransaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.SendChannel
// TODO: delete this entire class and use managed transactions, instead
interface TransactionSender {
fun start(scope: CoroutineScope)
fun stop()
fun notifyOnChange(channel: SendChannel<List<PendingTransaction>>)
/** only necessary when there is a long delay between starting a transaction and beginning to create it. Like when sweeping a wallet that first needs to be scanned. */
// suspend fun initTransaction(zatoshiValue: Long, toAddress: String, memo: String, fromAccountIndex: Int): ManagedTransaction
// suspend fun prepareTransaction(amount: Long, address: String, memo: String): PendingTransaction?
// suspend fun sendPreparedTransaction(encoder: TransactionEncoder, tx: PendingTransaction): PendingTransaction
suspend fun cleanupPreparedTransaction(tx: PendingTransaction)
// suspend fun sendToAddress(encoder: TransactionEncoder, zatoshi: Long, toAddress: String, memo: String = "", fromAccountId: Int = 0): PendingTransaction
suspend fun cancel(existingTransaction: PendingTransaction): Unit?
var onSubmissionError: ((Throwable) -> Boolean)?
}
class SendResult

View File

@ -12,15 +12,32 @@ import kotlinx.coroutines.withContext
import okio.Okio
import java.io.File
/**
* Class responsible for encoding a transaction in a consistent way. This bridges the gap by
* behaving like a stateless API so that callers can request [createTransaction] and receive a
* result, even though there are intermediate database interactions.
*
* @property rustBackend the instance of RustBackendWelding to use for creating and validating.
* @property repository the repository that stores information about the transactions being created
* such as the raw bytes and raw txId.
*/
class WalletTransactionEncoder(
private val rustBackend: RustBackendWelding,
private val repository: TransactionRepository
) : TransactionEncoder {
/**
* Creates a transaction, throwing an exception whenever things are missing. When the provided wallet implementation
* doesn't throw an exception, we wrap the issue into a descriptive exception ourselves (rather than using
* double-bangs for things).
* Creates a transaction, throwing an exception whenever things are missing. When the provided
* wallet implementation doesn't throw an exception, we wrap the issue into a descriptive
* exception ourselves (rather than using double-bangs for things).
*
* @param spendingKey the key associated with the notes that will be spent.
* @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 fromAccountIndex the optional account id to use. By default, the 1st account is used.
*
* @return the successfully encoded transaction or an exception
*/
override suspend fun createTransaction(
spendingKey: String,
@ -37,6 +54,10 @@ class WalletTransactionEncoder(
/**
* Utility function to help with validation. This is not called during [createTransaction]
* because this class asserts that all validation is done externally by the UI, for now.
*
* @param address the address to validate
*
* @return true when the given address is a valid z-addr
*/
override suspend fun isValidShieldedAddress(address: String): Boolean = withContext(IO) {
rustBackend.isValidShieldedAddr(address)
@ -45,6 +66,10 @@ class WalletTransactionEncoder(
/**
* Utility function to help with validation. This is not called during [createTransaction]
* because this class asserts that all validation is done externally by the UI, for now.
*
* @param address the address to validate
*
* @return true when the given address is a valid t-addr
*/
override suspend fun isValidTransparentAddress(address: String): Boolean = withContext(IO) {
rustBackend.isValidTransparentAddr(address)
@ -54,21 +79,23 @@ class WalletTransactionEncoder(
* Does the proofs and processing required to create a transaction to spend funds and inserts
* the result in the database. On average, this call takes over 10 seconds.
*
* @param value the zatoshi value to send
* @param toAddress the destination address
* @param memo the memo, which is not augmented in any way
* @param spendingKey the key associated with the notes that will be spent.
* @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 fromAccountIndex the optional account id to use. By default, the 1st account is used.
*
* @return the row id in the transactions table that contains the spend transaction
* or -1 if it failed
* @return the row id in the transactions table that contains the spend transaction or -1 if it
* failed.
*/
private suspend fun createSpend(
spendingKey: String,
value: Long,
zatoshi: Long,
toAddress: String,
memo: ByteArray? = byteArrayOf(),
fromAccountIndex: Int = 0
): Long = withContext(IO) {
twigTask("creating transaction to spend $value zatoshi to" +
twigTask("creating transaction to spend $zatoshi zatoshi to" +
" ${toAddress.masked()} with memo $memo") {
try {
ensureParams((rustBackend as RustBackend).pathParamsDir)
@ -77,7 +104,7 @@ class WalletTransactionEncoder(
fromAccountIndex,
spendingKey,
toAddress,
value,
zatoshi,
memo
)
} catch (t: Throwable) {
@ -166,6 +193,8 @@ class WalletTransactionEncoder(
/**
* Http client is only used for downloading sapling spend and output params data, which are
* necessary for the wallet to scan blocks.
*
* @return an http client suitable for downloading params data.
*/
private fun createHttpClient(): OkHttpClient {
//TODO: add logging and timeouts