SDK changes to support new demo app.

- Convert status flags into stream of statuses, instead.
- Improve logging during transaction submission
- Database corrections. Aparently Room has gotten more strict with schema parsing and this required lots of corrections mainly around get nullability correct for DB fields.
- Simplify Synchronizer creation via constructor functions. Created one function for maximum simplicity and another for maximum flexibility.
- Changed logic for Wallet initialization to simplify dependencies and allow for optional access to private keys for wallet apps
- Created TransactionRepository that leverages the paging library for Room
- Provided sample implementation of bridging to a key manager in a way where wallet apps do not have to modify their existing code.
- Made it easier to clear the wallet data that can be repopulated from the blockchain
- Allowed for better cleanup of heavy-weight lightwalletd services by adding a shutdown API call
This commit is contained in:
Kevin Gorham 2019-10-21 06:26:02 -04:00
parent 190f7f5548
commit f89d2be250
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
24 changed files with 513 additions and 422 deletions

View File

@ -6,12 +6,13 @@ buildscript {
]
ext.versions = [
'architectureComponents': [
'lifecycle': '2.2.0-alpha04',
'room': '2.1.0-rc01'
'lifecycle': '2.2.0-alpha05',
'room': '2.2.0',
'paging': '2.1.0'
],
'grpc':'1.21.0',
'kotlin': '1.3.50',
'coroutines': '1.3.0-M1',
'coroutines': '1.3.2',
'junitJupiter': '5.5.0-M1'
]
repositories {
@ -22,7 +23,7 @@ buildscript {
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.0-alpha11'
classpath 'com.android.tools.build:gradle:3.6.0-beta01'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
classpath "org.jetbrains.kotlin:kotlin-allopen:${versions.kotlin}"
classpath "org.jetbrains.dokka:dokka-gradle-plugin:0.9.18"
@ -50,7 +51,7 @@ apply plugin: 'org.mozilla.rust-android-gradle.rust-android'
apply plugin: 'org.owasp.dependencycheck'
group = 'cash.z.android.wallet'
version = '1.0.0-alpha02'
version = '1.0.0-alpha03'
repositories {
google()
@ -65,7 +66,7 @@ android {
defaultConfig {
minSdkVersion buildConfig.minSdkVersion
targetSdkVersion buildConfig.targetSdkVersion
versionCode = 1_00_00_002 // last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release.
versionCode = 1_00_00_003 // last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release.
versionName = "$version"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
@ -195,6 +196,7 @@ dependencies {
// Architecture Components: Room
implementation "androidx.room:room-runtime:${versions.architectureComponents.room}"
implementation "androidx.room:room-common:${versions.architectureComponents.room}"
implementation "androidx.paging:paging-runtime-ktx:${versions.architectureComponents.paging}"
kapt "androidx.room:room-compiler:${versions.architectureComponents.room}"
// Kotlin
@ -233,7 +235,7 @@ dependencies {
androidTestImplementation 'org.mockito:mockito-android:2.25.1'
androidTestImplementation "androidx.test:runner:1.2.0"
androidTestImplementation "androidx.test:core:1.2.0"
androidTestImplementation "androidx.arch.core:core-testing:2.1.0-rc01"
androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test:runner:1.2.0'
}

View File

@ -1,8 +1,12 @@
package cash.z.wallet.sdk.demoapp
import android.app.Application
import cash.z.wallet.sdk.demoapp.util.DemoConfig
class App : Application() {
var defaultConfig = DemoConfig()
override fun onCreate() {
instance = this
super.onCreate()

View File

@ -39,7 +39,10 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
override fun onClear() {
ledger.close()
synchronizer.stop()
(synchronizer as SdkSynchronizer).apply {
stop()
clearData()
}
}
private fun monitorStatus() {

View File

@ -2,6 +2,7 @@ package cash.z.wallet.sdk.block
import androidx.annotation.VisibleForTesting
import cash.z.wallet.sdk.annotation.OpenForTesting
import cash.z.wallet.sdk.block.CompactBlockProcessor.State.*
import cash.z.wallet.sdk.data.TransactionRepository
import cash.z.wallet.sdk.data.Twig
import cash.z.wallet.sdk.data.twig
@ -15,12 +16,12 @@ import cash.z.wallet.sdk.ext.ZcashSdk.REWIND_DISTANCE
import cash.z.wallet.sdk.ext.ZcashSdk.SAPLING_ACTIVATION_HEIGHT
import cash.z.wallet.sdk.ext.retryUpTo
import cash.z.wallet.sdk.ext.retryWithBackoff
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.jni.RustBackendWelding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
@ -37,15 +38,17 @@ import kotlin.math.roundToInt
class CompactBlockProcessor(
internal val downloader: CompactBlockDownloader,
private val repository: TransactionRepository,
private val rustBackend: RustBackendWelding
private val rustBackend: RustBackendWelding,
minimumHeight: Int = SAPLING_ACTIVATION_HEIGHT
) {
var onErrorListener: ((Throwable) -> Boolean)? = null
var isConnected: Boolean = false
var isSyncing: Boolean = false
var isScanning: Boolean = false
private val progressChannel = ConflatedBroadcastChannel(0)
private var isStopped = false
private val consecutiveChainErrors = AtomicInteger(0)
private val lowerBoundHeight: Int = max(SAPLING_ACTIVATION_HEIGHT, minimumHeight - MAX_REORG_SIZE)
private val _state: ConflatedBroadcastChannel<State> = ConflatedBroadcastChannel(Initialized)
val state = _state.asFlow()
fun progress(): ReceiveChannel<Int> = progressChannel.openSubscription()
@ -62,8 +65,6 @@ class CompactBlockProcessor(
val result = processNewBlocks()
// immediately process again after failures in order to download new blocks right away
if (result < 0) {
isSyncing = false
isScanning = false
consecutiveChainErrors.set(0)
twig("Successfully processed new blocks. Sleeping for ${POLL_INTERVAL}ms")
delay(POLL_INTERVAL)
@ -77,16 +78,16 @@ class CompactBlockProcessor(
consecutiveChainErrors.getAndIncrement()
}
}
} while (isActive && !isStopped)
} while (isActive && _state.value !is Stopped)
twig("processor complete")
stop()
}
fun stop() {
isStopped = true
suspend fun stop() {
setState(Stopped)
}
fun fail(error: Throwable) {
private suspend fun fail(error: Throwable) {
stop()
twig("${error.message}")
throw error
@ -103,27 +104,33 @@ class CompactBlockProcessor(
// define ranges
val latestBlockHeight = downloader.getLatestBlockHeight()
isConnected = true // no exception on downloader call
isSyncing = true
val lastDownloadedHeight = max(getLastDownloadedHeight(), SAPLING_ACTIVATION_HEIGHT - 1)
val lastDownloadedHeight = getLastDownloadedHeight()
val lastScannedHeight = getLastScannedHeight()
val boundedLastDownloadedHeight = max(lastDownloadedHeight, lowerBoundHeight - 1)
twig("latestBlockHeight: $latestBlockHeight\tlastDownloadedHeight: $lastDownloadedHeight" +
"\tlastScannedHeight: $lastScannedHeight")
twig(
"latestBlockHeight: $latestBlockHeight\tlastDownloadedHeight: $lastDownloadedHeight" +
"\tlastScannedHeight: $lastScannedHeight\tlowerBoundHeight: $lowerBoundHeight"
)
// as long as the database has the sapling tree (like when it's initialized from a checkpoint) we can avoid
// downloading earlier blocks so take the larger of these two numbers
val rangeToDownload = (max(lastDownloadedHeight, lastScannedHeight) + 1)..latestBlockHeight
val rangeToDownload = (max(boundedLastDownloadedHeight, lastScannedHeight) + 1)..latestBlockHeight
val rangeToScan = (lastScannedHeight + 1)..latestBlockHeight
setState(Downloading)
downloadNewBlocks(rangeToDownload)
val error = validateNewBlocks(rangeToScan)
setState(Validating)
var error = validateNewBlocks(rangeToScan)
if (error < 0) {
// in theory, a scan should not fail after validation succeeds but maybe consider
// changing the rust layer to return the failed block height whenever scan does fail
// rather than a boolean
setState(Scanning)
val success = scanNewBlocks(rangeToScan)
if (!success) throw CompactBlockProcessorException.FailedScan
else setState(Synced)
-1
} else {
error
@ -136,6 +143,7 @@ class CompactBlockProcessor(
if (range.isEmpty()) {
twig("no blocks to download")
} else {
_state.send(Downloading)
Twig.sprout("downloading")
twig("downloading blocks in range $range")
@ -181,9 +189,7 @@ class CompactBlockProcessor(
}
Twig.sprout("scanning")
twig("scanning blocks in range $range")
isScanning = true
val result = rustBackend.scanBlocks()
isScanning = false
Twig.clip("scanning")
return result
}
@ -196,15 +202,13 @@ class CompactBlockProcessor(
}
private fun onConnectionError(throwable: Throwable): Boolean {
isConnected = false
isSyncing = false
isScanning = false
_state.offer(Disconnected)
return onErrorListener?.invoke(throwable) ?: true
}
private fun determineLowerBound(errorHeight: Int): Int {
val offset = Math.min(MAX_REORG_SIZE, REWIND_DISTANCE * (consecutiveChainErrors.get() + 1))
return Math.max(errorHeight - offset, SAPLING_ACTIVATION_HEIGHT)
return Math.max(errorHeight - offset, lowerBoundHeight)
}
suspend fun getLastDownloadedHeight() = withContext(IO) {
@ -214,4 +218,19 @@ class CompactBlockProcessor(
suspend fun getLastScannedHeight() = withContext(IO) {
repository.lastScannedHeight()
}
suspend fun setState(newState: State) {
_state.send(newState)
}
sealed class State {
interface Connected
object Downloading : Connected, State()
object Validating : Connected, State()
object Scanning : Connected, State()
object Synced : Connected, State()
object Disconnected : State()
object Stopped : State()
object Initialized : State()
}
}

View File

@ -0,0 +1,96 @@
package cash.z.wallet.sdk.data
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.paging.PagedList
import androidx.paging.toLiveData
import androidx.room.Room
import androidx.room.RoomDatabase
import cash.z.wallet.sdk.db.BlockDao
import cash.z.wallet.sdk.db.DerivedDataDb
import cash.z.wallet.sdk.db.TransactionDao
import cash.z.wallet.sdk.entity.ClearedTransaction
import cash.z.wallet.sdk.entity.Transaction
import cash.z.wallet.sdk.ext.ZcashSdk
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
/**
* Example of a repository that leverages the Room paging library to return a [PagedList] of
* transactions. Consumers can register as a page listener and receive an interface that allows for
* efficiently paging data.
*
* @param pageSize transactions per page. This influences pre-fetch and memory configuration.
*/
open class PagedTransactionRepository(
private val derivedDataDb: DerivedDataDb,
private val pageSize: Int = 10
) : TransactionRepository {
/**
* Constructor that creates the database.
*/
constructor(
context: Context,
pageSize: Int = 10,
dataDbName: String = ZcashSdk.DB_DATA_NAME
) : this(
Room.databaseBuilder(context, DerivedDataDb::class.java, dataDbName)
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
.build(),
pageSize
)
private val blocks: BlockDao = derivedDataDb.blockDao()
private val transactions: TransactionDao = derivedDataDb.transactionDao()
private val transactionLivePagedList =
transactions.getReceivedTransactions().toLiveData(pageSize)
/**
* The primary function of this repository. Callers to this method receive a [PagedList]
* snapshot of the current data source that can then be queried by page, via the normal [List]
* API. Meaning, the details of the paging behavior, including caching and pre-fetch are handled
* automatically. This integrates directly with the RecyclerView for seamless display of a large
* number of transactions.
*/
fun setTransactionPageListener(
lifecycleOwner: LifecycleOwner,
listener: (PagedList<out ClearedTransaction>) -> Unit
) {
transactionLivePagedList.removeObservers(lifecycleOwner)
transactionLivePagedList.observe(lifecycleOwner, Observer { transactions ->
listener(transactions)
})
}
override fun lastScannedHeight(): Int {
return blocks.lastScannedHeight()
}
override fun isInitialized(): Boolean {
return blocks.count() > 0
}
override suspend fun findTransactionById(txId: Long): Transaction? = withContext(IO) {
twig("finding transaction with id $txId on thread ${Thread.currentThread().name}")
val transaction = transactions.findById(txId)
twig("found ${transaction?.id}")
transaction
}
override suspend fun findTransactionByRawId(rawTxId: ByteArray): Transaction? = withContext(IO) {
transactions.findByRawId(rawTxId)
}
override suspend fun deleteTransactionById(txId: Long) = withContext(IO) {
twigTask("deleting transaction with id $txId") {
transactions.deleteById(txId)
}
}
fun close() {
derivedDataDb.close()
}
}

View File

@ -105,14 +105,11 @@ class PersistentTransactionManager(private val db: PendingTransactionDb) : Trans
override suspend fun manageSubmission(service: LightWalletService, pendingTransaction: SignedTransaction) {
var tx = pendingTransaction as PendingTransaction
try {
twig("managing the preparation to submit transaction memo: ${tx.memo} amount: ${tx.value}")
twig("submitting transaction to lightwalletd - memo: ${tx.memo} amount: ${tx.value}")
val response = service.submitTransaction(pendingTransaction.raw!!)
twig("management of submit transaction completed with response: ${response.errorCode}: ${response.errorMessage}")
tx = if (response.errorCode < 0) {
tx.copy(errorMessage = response.errorMessage, errorCode = response.errorCode)
} else {
tx.copy(errorMessage = null, errorCode = response.errorCode)
}
val error = response.errorCode < 0
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)
} catch (t: Throwable) {
twig("error while managing submitting transaction: ${t.message} caused by: ${t.cause}")
} finally {
@ -141,7 +138,7 @@ class PersistentTransactionManager(private val db: PendingTransactionDb) : Trans
suspend fun manageMined(pendingTx: PendingTransaction, matchingMinedTx: Transaction) = withContext(IO) {
twig("a pending transaction has been mined!")
val tx = pendingTx.copy(minedHeight = matchingMinedTx.minedHeight)
val tx = pendingTx.copy(minedHeight = matchingMinedTx.minedHeight!!)
dao.insert(tx)
}

View File

@ -241,14 +241,17 @@ class PersistentTransactionSender (
tx.rawTransactionId?.let {
ledger.findTransactionByRawId(tx.rawTransactionId)
}?.let {
twig("matching transaction found! $tx")
(manager as PersistentTransactionManager).manageMined(tx, it)
refreshSentTransactions()
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}")
}
}

View File

@ -1,130 +0,0 @@
package cash.z.wallet.sdk.data
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
import cash.z.wallet.sdk.db.*
import cash.z.wallet.sdk.entity.ClearedTransaction
import cash.z.wallet.sdk.entity.Transaction
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
/**
* Repository that does polling for simplicity. We will implement an alternative version that uses live data as well as
* one that creates triggers and then reference them here. For now this is the most basic example of keeping track of
* changes.
*
* @param limit the max number of transactions to return when polling for changes. After a wallet has been a round for a
* long time, returning ALL transactions might be overkill. Instead, it may be more efficient to only return a limited
* number of transactions, like the most recent transaction that has changed.
*/
open class PollingTransactionRepository(
private val derivedDataDb: DerivedDataDb,
private val pollFrequencyMillis: Long = DEFAULT_TX_POLL_INTERVAL_MS,
private val limit: Int = Int.MAX_VALUE
) : TransactionRepository {
/**
* Constructor that creates the database.
*/
constructor(
context: Context,
dataDbName: String,
pollFrequencyMillis: Long = DEFAULT_TX_POLL_INTERVAL_MS
) : this(
Room.databaseBuilder(context, DerivedDataDb::class.java, dataDbName)
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
.build(),
pollFrequencyMillis
)
private val blocks: BlockDao = derivedDataDb.blockDao()
private val receivedNotes: ReceivedDao = derivedDataDb.receivedDao()
private val sentNotes: SentDao = derivedDataDb.sentDao()
private val transactions: TransactionDao = derivedDataDb.transactionDao()
protected var pollingJob: Job? = null
override fun lastScannedHeight(): Int {
return blocks.lastScannedHeight()
}
override fun isInitialized(): Boolean {
return blocks.count() > 0
}
override suspend fun findTransactionById(txId: Long): Transaction? = withContext(IO) {
twig("finding transaction with id $txId on thread ${Thread.currentThread().name}")
val transaction = transactions.findById(txId)
twig("found ${transaction?.id}")
transaction
}
override suspend fun findTransactionByRawId(rawTxId: ByteArray): Transaction? = withContext(IO) {
transactions.findByRawId(rawTxId)
}
override suspend fun deleteTransactionById(txId: Long) = withContext(IO) {
twigTask("deleting transaction with id $txId") {
transactions.deleteById(txId)
}
}
override suspend fun getClearedTransactions(): List<ClearedTransaction> = withContext(IO) {
transactions.getSentTransactions(limit) + transactions.getReceivedTransactions(limit)
}
override suspend fun monitorChanges(listener: () -> Unit) = withContext(IO) {
// since the only thing mutable is unmined transactions, we can simply check for new data rows rather than doing any deep comparisons
// in the future we can leverage triggers instead
pollingJob?.cancel()
pollingJob = launch {
val txCount = ValueHolder(-1, "Transaction Count")
val unminedCount = ValueHolder(-1, "Unmined Transaction Count")
val sentCount = ValueHolder(-1, "Sent Transaction Count")
val receivedCount = ValueHolder(-1, "Received Transaction Count")
while (coroutineContext.isActive) {
// we check all conditions to avoid duplicate notifications whenever a change impacts multiple tables
// if counting becomes slower than the blocktime (highly unlikely) then this could be optimized to call the listener early and continue counting afterward but there's no need for that complexity now
if (txCount.changed(transactions.count())
|| unminedCount.changed(transactions.countUnmined())
|| sentCount.changed(sentNotes.count())
|| receivedCount.changed(receivedNotes.count())
) {
twig("Notifying listener that changes have been detected in transactions!")
listener.invoke()
} else {
//twig("No changes detected in transactions.")
}
delay(pollFrequencyMillis)
}
}
}
fun stop() {
pollingJob?.cancel().also { pollingJob = null }
derivedDataDb.close()
}
companion object {
const val DEFAULT_TX_POLL_INTERVAL_MS = 5_000L
}
}
/**
* Reduces some of the boilerplate of checking a value for changes.
*/
internal class ValueHolder<T>(var value: T, val description: String = "Value") {
/**
* Hold the new value and report whether it has changed.
*/
fun changed(newValue: T): Boolean {
return if (newValue == value) {
false
} else {
twig("$description changed from $value to $newValue")
value = newValue
true
}
}
}

View File

@ -1,18 +1,82 @@
package cash.z.wallet.sdk.data
import android.content.Context
import cash.z.wallet.sdk.block.CompactBlockDbStore
import cash.z.wallet.sdk.block.CompactBlockDownloader
import cash.z.wallet.sdk.block.CompactBlockProcessor
import cash.z.wallet.sdk.block.CompactBlockProcessor.State.*
import cash.z.wallet.sdk.block.CompactBlockStore
import cash.z.wallet.sdk.data.Synchronizer.Status.*
import cash.z.wallet.sdk.entity.ClearedTransaction
import cash.z.wallet.sdk.entity.PendingTransaction
import cash.z.wallet.sdk.entity.SentTransaction
import cash.z.wallet.sdk.exception.SynchronizerException
import cash.z.wallet.sdk.exception.WalletException
import cash.z.wallet.sdk.secure.Wallet
import cash.z.wallet.sdk.service.LightWalletGrpcService
import cash.z.wallet.sdk.service.LightWalletService
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlin.coroutines.CoroutineContext
/**
* Constructor function for building a Synchronizer, given the bare minimum amount of information
* necessary to do so.
*/
@Suppress("FunctionName")
fun Synchronizer(
appContext: Context,
lightwalletdHost: String,
keyManager: Wallet.KeyManager,
birthdayHeight: Int? = null
): Synchronizer {
val wallet = Wallet().also {
val privateKeyMaybe = it.initialize(appContext, keyManager.seed, birthdayHeight)
if (privateKeyMaybe != null) keyManager.key = privateKeyMaybe[0]
}
return Synchronizer(
appContext,
wallet,
lightwalletdHost,
keyManager
)
}
/**
* Constructor function for building a Synchronizer in the most flexible way possible. This allows
* a wallet maker to customize any subcomponent of the Synchronzier.
*/
@Suppress("FunctionName")
fun Synchronizer(
appContext: Context,
wallet: Wallet,
lightwalletdHost: String,
keyManager: Wallet.KeyManager,
ledger: TransactionRepository = PagedTransactionRepository(appContext),
manager: TransactionManager = PersistentTransactionManager(appContext),
service: LightWalletService = LightWalletGrpcService(appContext, lightwalletdHost),
sender: TransactionSender = PersistentTransactionSender(manager, service, ledger),
blockStore: CompactBlockStore = CompactBlockDbStore(appContext),
downloader: CompactBlockDownloader = CompactBlockDownloader(service, blockStore),
processor: CompactBlockProcessor = CompactBlockProcessor(downloader, ledger, wallet.rustBackend),
encoder: TransactionEncoder = WalletTransactionEncoder(wallet, ledger, keyManager)
): Synchronizer {
// ties everything together
return SdkSynchronizer(
wallet,
ledger,
sender,
processor,
encoder
)
}
/**
* A synchronizer that attempts to remain operational, despite any number of errors that can occur. It acts as the glue
* that ties all the pieces of the SDK together. Each component of the SDK is designed for the potential of stand-alone
@ -20,7 +84,7 @@ import kotlin.coroutines.CoroutineContext
* that demonstrates how all the 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 wallet the component that wraps the JNI layer that interacts with the rust backend and manages wallet config.
* @param wallet An initialized wallet. This component wraps the rust backend and manages wallet config.
* @param repository the component that exposes streams of wallet transaction information.
* @param sender the component responsible for sending transactions to lightwalletd in order to spend funds.
* @param processor the component that saves the downloaded compact blocks to the cache and then scans those blocks for
@ -51,19 +115,17 @@ class SdkSynchronizer (
//
/**
* A property that is true while a connection to the lightwalletd server exists.
* Indicates the status of this Synchronizer. This implementation basically simplifies the
* status of the processor to focus only on the high level states that matter most.
*/
override val isConnected: Boolean get() = processor.isConnected
/**
* A property that is true while actively downloading blocks from lightwalletd.
*/
override val isSyncing: Boolean get() = processor.isSyncing
/**
* A property that is true while actively scanning the cache of compact blocks for transactions.
*/
override val isScanning: Boolean get() = processor.isScanning
override val status = processor.state.map {
when (it) {
is Synced -> SYNCED
is Stopped -> STOPPED
is Disconnected -> DISCONNECTED
else -> SYNCING
}
}
//
@ -99,7 +161,7 @@ class SdkSynchronizer (
override var onCriticalErrorHandler: ((Throwable?) -> Boolean)? = null
/**
* A callback to invoke whenver a processor error is encountered. Returning true signals that the error was handled
* A callback to invoke whenever a processor error is encountered. Returning true signals that the error was handled
* and a retry attempt should be made, if possible. This callback is not called on the main thread so any UI work
* would need to switch context to the main thread.
*/
@ -132,12 +194,10 @@ class SdkSynchronizer (
// TODO: this doesn't work as intended. Refactor to improve the cancellation behavior (i.e. what happens when one job fails) by making launchTransactionMonitor throw an exception
coroutineScope.launch {
initWallet()
startSender(this)
launchProgressMonitor()
launchPendingMonitor()
launchTransactionMonitor()
onReady()
}
return this
@ -151,24 +211,6 @@ class SdkSynchronizer (
sender.start(parentScope)
}
/**
* Initialize the wallet, which involves populating data tables based on the latest state of the wallet.
*/
private suspend fun initWallet() = withContext(IO) {
try {
wallet.initialize()
} catch (e: WalletException.AlreadyInitializedException) {
twig("Warning: wallet already initialized but this is safe to ignore " +
"because the SDK automatically detects where to start downloading.")
} catch (f: WalletException.FalseStart) {
if (recoverFrom(f)) {
twig("Warning: had a wallet init error but we recovered!")
} else {
twig("Error: false start while initializing wallet!")
}
}
}
// TODO: this is a work in progress. We could take drastic measures to automatically recover from certain critical
// errors and alert the user but this might be better to do at the app level, rather than SDK level.
private fun recoverFrom(error: WalletException.FalseStart): Boolean {
@ -186,9 +228,15 @@ class SdkSynchronizer (
* was never previously started.
*/
override fun stop() {
coroutineScope.cancel()
coroutineScope.launch {
processor.stop()
coroutineScope.cancel()
}
}
fun clearData() {
wallet.clear()
}
//
// Monitors
@ -223,19 +271,6 @@ class SdkSynchronizer (
twig("done monitoring for pending changes and balance changes")
}
private fun CoroutineScope.launchTransactionMonitor(): Job = launch {
ledger.monitorChanges(::onTransactionsChanged)
}
fun onTransactionsChanged() {
coroutineScope.launch {
twig("triggering a balance update because transactions have changed")
refreshBalance()
clearedChannel.send(ledger.getClearedTransactions())
}
twig("done handling changed transactions")
}
suspend fun refreshBalance() = withContext(IO) {
if (!balanceChannel.isClosedForSend) {
balanceChannel.send(wallet.getBalanceInfo())
@ -247,6 +282,10 @@ class SdkSynchronizer (
private fun CoroutineScope.onReady() = launch(CoroutineExceptionHandler(::onCriticalError)) {
twig("Synchronizer Ready. Starting processor!")
processor.onErrorListener = ::onProcessorError
status.filter { it == SYNCED }.onEach {
twig("Triggering an automatic balance refresh since the processor is synced!")
refreshBalance()
}.launchIn(this)
processor.start()
twig("Synchronizer onReady complete. Processor start has exited!")
}
@ -270,8 +309,15 @@ class SdkSynchronizer (
private fun onProcessorError(error: Throwable): Boolean {
twig("ERROR while processing data: $error")
if (onProcessorErrorHandler == null) {
twig("WARNING: falling back to the default behavior for processor errors. To add" +
" custom behavior, set synchronizer.onProcessorErrorHandler to" +
" a non-null value")
return true
}
return onProcessorErrorHandler?.invoke(error)?.also {
if (it) twig("processor error handler signaled that we should try again!")
twig("processor error handler signaled that we should " +
"${if (it) "try again" else "abort"}!")
} == true
}

View File

@ -6,6 +6,7 @@ import cash.z.wallet.sdk.entity.SentTransaction
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.Flow
/**
* Primary interface for interacting with the SDK. Defines the contract that specific implementations like
@ -84,23 +85,10 @@ interface Synchronizer {
//
/**
* A flag indicating whether this Synchronizer is connected to its lightwalletd server. When false, a UI element
* may want to turn red.
* A flow of values representing the [Status] of this Synchronizer. As the status changes, a new
* value will be emitted by this flow.
*/
val isConnected: Boolean
/**
* A flag indicating whether this Synchronizer is actively downloading compact blocks. When true, a UI element
* may want to turn yellow.
*/
val isSyncing: Boolean
/**
* A flag indicating whether this Synchronizer is actively decrypting compact blocks, searching for transactions.
* When true, a UI element may want to turn yellow.
*/
val isScanning: Boolean
val status: Flow<Status>
//
@ -168,4 +156,30 @@ interface Synchronizer {
* error is unrecoverable and the sender should [stop].
*/
var onSubmissionErrorHandler: ((Throwable?) -> Boolean)?
enum class Status {
/**
* Indicates that [stop] has been called on this Synchronizer and it will no longer be used.
*/
STOPPED,
/**
* Indicates that this Synchronizer is disconnected from its lightwalletd server.
* When set, a UI element may want to turn red.
*/
DISCONNECTED,
/**
* Indicates that this Synchronizer is not yet synced and therefore should not broadcast
* transactions because it does not have the latest data. When set, a UI element may want
* to turn yellow.
*/
SYNCING,
/**
* Indicates that this Synchronizer is fully up to date and ready for all wallet functions.
* When set, a UI element may want to turn green.
*/
SYNCED
}
}

View File

@ -1,6 +1,5 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.entity.ClearedTransaction
import cash.z.wallet.sdk.entity.Transaction
interface TransactionRepository {
@ -9,6 +8,4 @@ interface TransactionRepository {
suspend fun findTransactionById(txId: Long): Transaction?
suspend fun findTransactionByRawId(rawTransactionId: ByteArray): Transaction?
suspend fun deleteTransactionById(txId: Long)
suspend fun getClearedTransactions(): List<ClearedTransaction>
suspend fun monitorChanges(listener: () -> Unit)
}

View File

@ -9,7 +9,8 @@ import kotlinx.coroutines.withContext
class WalletTransactionEncoder(
private val wallet: Wallet,
private val repository: TransactionRepository
private val repository: TransactionRepository,
private val spendingKeyProvider: Wallet.SpendingKeyProvider
) : TransactionEncoder {
/**
@ -18,7 +19,7 @@ class WalletTransactionEncoder(
* double-bangs for things).
*/
override suspend fun create(zatoshi: Long, toAddress: String, memo: String): EncodedTransaction = withContext(IO) {
val transactionId = wallet.createSpend(zatoshi, toAddress, memo)
val transactionId = wallet.createSpend(spendingKeyProvider.key, zatoshi, toAddress, memo)
val transaction = repository.findTransactionById(transactionId)
?: throw TransactionNotFoundException(transactionId)
EncodedTransaction(transaction.transactionId, transaction.raw

View File

@ -1,5 +1,6 @@
package cash.z.wallet.sdk.db
import androidx.paging.DataSource
import androidx.room.*
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.entity.Transaction
@ -97,7 +98,7 @@ interface TransactionDao {
ORDER BY block IS NOT NULL, height DESC, time DESC, txid DESC
LIMIT :limit
""")
fun getSentTransactions(limit: Int = Int.MAX_VALUE): List<SentTransaction>
fun getSentTransactions(limit: Int = Int.MAX_VALUE): DataSource.Factory<Int, SentTransaction>
/**
@ -122,6 +123,6 @@ interface TransactionDao {
ORDER BY minedheight DESC, blocktimeinseconds DESC, id DESC
LIMIT :limit
""")
fun getReceivedTransactions(limit: Int = Int.MAX_VALUE): List<ReceivedTransaction>
fun getReceivedTransactions(limit: Int = Int.MAX_VALUE): DataSource.Factory<Int, ReceivedTransaction>
}

View File

@ -10,7 +10,7 @@ import androidx.room.Ignore
primaryKeys = ["account"]
)
data class Account(
val account: Int = 0,
val account: Int? = 0,
@ColumnInfo(name = "extfvk")
val extendedFullViewingKey: String = "",

View File

@ -2,28 +2,37 @@ package cash.z.wallet.sdk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import org.jetbrains.annotations.NotNull
@Entity(primaryKeys = ["height"], tableName = "blocks")
data class Block(
val height: Int,
val time: Int?,
val height: Int?,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB, name = "hash")
@NotNull
val hash: ByteArray,
@NotNull
val time: Int,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB, name = "sapling_tree")
@NotNull
val saplingTree: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
return (other is Block)
&& height == other.height
&& time == other.time
&& saplingTree.contentEquals(other.saplingTree)
if (other !is Block) return false
if (height != other.height) return false
if (!hash.contentEquals(other.hash)) return false
if (time != other.time) return false
if (!saplingTree.contentEquals(other.saplingTree)) return false
return true
}
override fun hashCode(): Int {
var result = height
result = 31 * result + (time ?: 0)
var result = height ?: 0
result = 31 * result + hash.contentHashCode()
result = 31 * result + time
result = 31 * result + saplingTree.contentHashCode()
return result
}
}

View File

@ -10,26 +10,20 @@ import androidx.room.ForeignKey
foreignKeys = [ForeignKey(
entity = Transaction::class,
parentColumns = ["id_tx"],
childColumns = ["tx"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
), ForeignKey(
entity = Transaction::class,
parentColumns = ["id_tx"],
childColumns = ["spent"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.SET_NULL
childColumns = ["tx"]
), ForeignKey(
entity = Account::class,
parentColumns = ["account"],
childColumns = ["account"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
childColumns = ["account"]
), ForeignKey(
entity = Transaction::class,
parentColumns = ["id_tx"],
childColumns = ["spent"]
)]
)
data class Received(
@ColumnInfo(name = "id_note")
val id: Int = 0,
val id: Int? = 0,
/**
* A reference to the transaction this note was received in
@ -48,9 +42,6 @@ data class Received(
*/
val spent: Int? = 0,
@ColumnInfo(name = "is_change")
val isChange: Boolean = false,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
val diversifier: ByteArray = byteArrayOf(),
@ -60,40 +51,11 @@ data class Received(
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
val nf: ByteArray = byteArrayOf(),
@ColumnInfo(name = "is_change")
val isChange: Boolean = false,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
val memo: ByteArray? = byteArrayOf()
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
return (other is Received)
&& id == other.id
&& transactionId == other.transactionId
&& outputIndex == other.outputIndex
&& account == other.account
&& value == other.value
&& spent == other.spent
&& diversifier.contentEquals(other.diversifier)
&& rcm.contentEquals(other.rcm)
&& nf.contentEquals(other.nf)
&& isChange == other.isChange
&& ((memo == null && other.memo == null)
|| (memo != null && other.memo != null && memo.contentEquals(other.memo)))
}
override fun hashCode(): Int {
var result = id
result = 31 * result + transactionId
result = 31 * result + outputIndex
result = 31 * result + account
result = 31 * result + value.toInt()
result = 31 * result + (if (isChange) 1 else 0)
result = 31 * result + (spent ?: 0)
result = 31 * result + diversifier.contentHashCode()
result = 31 * result + rcm.contentHashCode()
result = 31 * result + nf.contentHashCode()
result = 31 * result + (memo?.contentHashCode() ?: 0)
return result
}
}

View File

@ -10,20 +10,16 @@ import androidx.room.ForeignKey
foreignKeys = [ForeignKey(
entity = Transaction::class,
parentColumns = ["id_tx"],
childColumns = ["tx"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
childColumns = ["tx"]
), ForeignKey(
entity = Account::class,
parentColumns = ["account"],
childColumns = ["from_account"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.SET_NULL
childColumns = ["from_account"]
)]
)
data class Sent(
@ColumnInfo(name = "id_note")
val id: Int = 0,
val id: Int? = 0,
@ColumnInfo(name = "tx")
val transactionId: Int = 0,
@ -34,7 +30,7 @@ data class Sent(
@ColumnInfo(name = "from_account")
val account: Int = 0,
val address: String,
val address: String = "",
val value: Long = 0,
@ -44,22 +40,29 @@ data class Sent(
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Sent) return false
return (other is Sent)
&& id == other.id
&& transactionId == other.transactionId
&& outputIndex == other.outputIndex
&& account == other.account
&& value == other.value
&& ((memo == null && other.memo == null) || (memo != null && other.memo != null && memo.contentEquals(other.memo)))
if (id != other.id) return false
if (transactionId != other.transactionId) return false
if (outputIndex != other.outputIndex) return false
if (account != other.account) return false
if (address != other.address) return false
if (value != other.value) return false
if (memo != null) {
if (other.memo == null) return false
if (!memo.contentEquals(other.memo)) return false
} else if (other.memo != null) return false
return true
}
override fun hashCode(): Int {
var result = id
var result = id ?: 0
result = 31 * result + transactionId
result = 31 * result + outputIndex
result = 31 * result + account
result = 31 * result + value.toInt()
result = 31 * result + address.hashCode()
result = 31 * result + value.hashCode()
result = 31 * result + (memo?.contentHashCode() ?: 0)
return result
}

View File

@ -17,26 +17,26 @@ import org.jetbrains.annotations.NotNull
foreignKeys = [ForeignKey(
entity = Block::class,
parentColumns = ["height"],
childColumns = ["block"],
onDelete = ForeignKey.CASCADE
childColumns = ["block"]
)]
)
data class Transaction(
@ColumnInfo(name = "id_tx")
val id: Long,
val id: Long?,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB, name = "txid")
@NotNull
val transactionId: ByteArray,
@ColumnInfo(name = "tx_index")
val transactionIndex: Int,
val transactionIndex: Int?,
val created: String?,
@ColumnInfo(name = "expiry_height")
val expiryHeight: Int,
val expiryHeight: Int?,
@ColumnInfo(name = "block")
val minedHeight: Int,
val minedHeight: Int?,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
val raw: ByteArray?
@ -48,6 +48,7 @@ data class Transaction(
if (id != other.id) return false
if (!transactionId.contentEquals(other.transactionId)) return false
if (transactionIndex != other.transactionIndex) return false
if (created != other.created) return false
if (expiryHeight != other.expiryHeight) return false
if (minedHeight != other.minedHeight) return false
if (raw != null) {
@ -61,12 +62,14 @@ data class Transaction(
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + transactionId.contentHashCode()
result = 31 * result + transactionIndex
result = 31 * result + expiryHeight
result = 31 * result + minedHeight
result = 31 * result + (transactionIndex ?: 0)
result = 31 * result + (created?.hashCode() ?: 0)
result = 31 * result + (expiryHeight ?: 0)
result = 31 * result + (minedHeight ?: 0)
result = 31 * result + (raw?.contentHashCode() ?: 0)
return result
}
}
@Entity(tableName = "pending_transactions")

View File

@ -48,6 +48,9 @@ sealed class WalletException(message: String, cause: Throwable? = null) : Runtim
object MissingParamsException : WalletException(
"Cannot send funds due to missing spend or output params and attempting to download them failed."
)
class BirthdayNotFoundException(directory: String, height: Int?) : WalletException(
"Unable to find birthday file for $height verify that $directory/$height.json exists."
)
class MalformattedBirthdayFilesException(directory: String, file: String) : WalletException(
"Failed to parse file $directory/$file verify that it is formatted as #####.json, " +
"where the first portion is an Int representing the height of the tree contained in the file"

View File

@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.provider.Settings
import cash.z.wallet.sdk.secure.Wallet
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
@ -23,9 +24,19 @@ class SampleSpendingKeyProvider(private val seedValue: String) : ReadWriteProper
}
@Deprecated(message = InsecureWarning.message)
class SampleSeedProvider(val seedValue: String) : ReadOnlyProperty<Any?, ByteArray> {
class SampleSeedProvider(val seed: ByteArray) : ReadOnlyProperty<Any?, ByteArray> {
constructor(seedValue: String) : this(seedValue.toByteArray())
override fun getValue(thisRef: Any?, property: KProperty<*>): ByteArray {
return seedValue.toByteArray()
return seed
}
}
@Deprecated(message = InsecureWarning.message)
class BlockingSeedProvider(val seed: ByteArray, val delay: Long = 5000L) : ReadOnlyProperty<Any?, ByteArray> {
constructor(seedValue: String, delayMillis: Long = 5000L) : this(seedValue.toByteArray(), delayMillis)
override fun getValue(thisRef: Any?, property: KProperty<*>): ByteArray {
Thread.sleep(delay)
return seed
}
}
@ -40,6 +51,26 @@ class SimpleProvider<T>(var value: T) : ReadWriteProperty<Any?, T> {
}
}
@Deprecated(message = InsecureWarning.message)
class BlockingProvider<T>(var value: T, val delay: Long = 5000L) : ReadWriteProperty<Any?, T> {
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
Thread.sleep(delay)
return value
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
Thread.sleep(delay)
this.value = value
}
}
@Deprecated(message = InsecureWarning.message)
class SampleKeyManager(val sampleSeed: ByteArray) : Wallet.KeyManager {
override lateinit var key: String
override val seed: ByteArray get() = sampleSeed
}
/**
* This is intentionally insecure. Wallet makers have told us storing keys is their specialty so we don't put a lot of
* energy here. A true implementation would create a key using user interaction, perhaps with a password they know that

View File

@ -4,25 +4,30 @@ import android.content.Context
import cash.z.wallet.sdk.data.twig
import cash.z.wallet.sdk.ext.ZcashSdk.OUTPUT_PARAM_FILE_NAME
import cash.z.wallet.sdk.ext.ZcashSdk.SPEND_PARAM_FILE_NAME
import java.io.File
/**
* Serves as the JNI boundary between the Kotlin and Rust layers. Functions in this class should
* not be called directly by code outside of the SDK. Instead, one of the higher-level components
* should be used such as Wallet.kt or CompactBlockProcessor.kt.
*/
internal object RustBackend : RustBackendWelding {
object RustBackend : RustBackendWelding {
private var loaded = false
private lateinit var dbDataPath: String
private lateinit var dbCachePath: String
lateinit var paramDestinationDir: String
/**
* Loads the library and initializes path variables. Although it is best to only call this
* function once, it is idempotent.
*/
override fun create(appContext: Context, dbCacheName: String, dbDataName: String): RustBackend {
twig("Creating RustBackend") {
// It is safe to call these things twice but not efficient. So we add a loose check and
// ignore the fact that it's not thread-safe.
if (!loaded) {
initLogs()
loadRustLibrary()
initLogs()
}
dbCachePath = appContext.getDatabasePath(dbCacheName).absolutePath
dbDataPath = appContext.getDatabasePath(dbDataName).absolutePath
@ -31,6 +36,12 @@ internal object RustBackend : RustBackendWelding {
return this
}
fun clear() {
twig("Deleting databases")
File(dbCachePath).delete()
File(dbDataPath).delete()
}
/**
* The first call made to this object in order to load the Rust backend library. All other calls
* will fail if this function has not been invoked.
@ -44,7 +55,6 @@ internal object RustBackend : RustBackendWelding {
}
}
//
// Wrapper Functions
//

View File

@ -6,8 +6,9 @@ import cash.z.wallet.sdk.data.twig
import cash.z.wallet.sdk.data.twigTask
import cash.z.wallet.sdk.exception.RustLayerException
import cash.z.wallet.sdk.exception.WalletException
import cash.z.wallet.sdk.exception.WalletException.MalformattedBirthdayFilesException
import cash.z.wallet.sdk.exception.WalletException.MissingBirthdayFilesException
import cash.z.wallet.sdk.exception.WalletException.*
import cash.z.wallet.sdk.ext.ZcashSdk.DB_CACHE_NAME
import cash.z.wallet.sdk.ext.ZcashSdk.DB_DATA_NAME
import cash.z.wallet.sdk.ext.ZcashSdk.OUTPUT_PARAM_FILE_NAME
import cash.z.wallet.sdk.ext.ZcashSdk.SAPLING_ACTIVATION_HEIGHT
import cash.z.wallet.sdk.ext.ZcashSdk.SPEND_PARAM_FILE_NAME
@ -23,8 +24,6 @@ import kotlinx.coroutines.withContext
import okio.Okio
import java.io.File
import java.io.InputStreamReader
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
/**
@ -33,61 +32,54 @@ import kotlin.properties.ReadWriteProperty
* The [cash.z.wallet.sdk.block.CompactBlockProcessor] handles all the remaining Rust backend
* functionality, related to processing blocks.
*/
class Wallet private constructor(
private val birthday: WalletBirthday,
private val rustBackend: RustBackendWelding,
private val paramDestinationDir: String,
private val seedProvider: ReadOnlyProperty<Any?, ByteArray>,
spendingKeyProvider: ReadWriteProperty<Any?, String>,
/** indexes of accounts ids. In the reference wallet, we only work with account 0 */
private val accountIds: Array<Int> = arrayOf(0)
) {
class Wallet {
lateinit var rustBackend: RustBackendWelding
var lowerBoundHeight: Int = SAPLING_ACTIVATION_HEIGHT
fun clear() {
if (::rustBackend.isInitialized) {
(rustBackend as RustBackend).clear()
} else {
twig("WARNING: attempted to clear an uninitialized wallet. No action was taken since " +
"the database paths have not yet been set.")
}
}
/**
* Construct a wallet from the given birthday. It will take care of downloading params, on first
* use, to a private cache directory as well as initializing the RustBackend. The file paths for
* these components are not configurable
* per github issue https://github.com/zcash/zcash-android-wallet-sdk/issues/30
* Initialize the wallet with the given seed and return the related private keys for each
* account specified or null if the wallet was previously initialized and block data exists on
* disk. When this method returns null, that signals that the wallet will need to retrieve the
* private keys from its own secure storage. In other words, the private keys are only given out
* once for each set of database files. Subsequent calls to [initialize] will only load the Rust
* library and return null.
*
* @param appContext the application context. This is used for loading a checkpoint
* corresponding to the wallet's birthday from the SDK assets directory and also for setting
* the path for storing the sapling params.
* @param birthday the date this wallet's seed was created.
* @param rustBackend the RustBackend that the rest of the SDK will use. It is initialized by
* by this class.
* @param seedProvider a read-only property that provides the seed.
* @param spendingKeyProvider a read-only property that provides the spending key.
* @param accountIds the ids of the accounts to use. Defaults to a array of 0.
*/
constructor(
appContext: Context,
rustBackend: RustBackendWelding,
seedProvider: ReadOnlyProperty<Any?, ByteArray>,
spendingKeyProvider: ReadWriteProperty<Any?, String>,
birthday: WalletBirthday = loadBirthdayFromAssets(appContext),
accountIds: Array<Int> = arrayOf(0)
) : this(
birthday = birthday,
rustBackend = rustBackend,
paramDestinationDir = (rustBackend as RustBackend).paramDestinationDir,
seedProvider = seedProvider,
spendingKeyProvider = spendingKeyProvider,
accountIds = accountIds
)
/**
* Delegate for storing spending keys. This will be used when the spending keys are created
* during initialization.
*/
private var spendingKeyStore by spendingKeyProvider
/**
* Initializes the wallet by creating the DataDb and pre-populating it with data corresponding
* to the birthday for this wallet.
* 'compactBlockCache.db' and 'transactionData.db' files are created by this function (if they
* do not already exist). These files can be given a prefix for scenarios where multiple wallets
* operate in one app--for instance, when sweeping funds from another wallet seed.
*
* @param appContext the application context.
* @param seed the seed to use for initializing this wallet.
* @param birthdayHeight the height corresponding to when the wallet seed was created. If null,
* this signals that the wallet is being born.
* @param numberOfAccounts the number of accounts to create from this seed.
* @param dbFileNamePrefix the optional prefix to add to the names of the database files.
*/
fun initialize(
firstRunStartHeight: Int = SAPLING_ACTIVATION_HEIGHT
): Int {
appContext: Context,
seed: ByteArray,
birthdayHeight: Int? = null,
numberOfAccounts: Int = 1,
dbFileNamePrefix: String = ""
): Array<String>? {
rustBackend = RustBackend.create(
appContext,
"${dbFileNamePrefix}$DB_CACHE_NAME",
"${dbFileNamePrefix}$DB_DATA_NAME"
)
try {
// only creates tables, if they don't exist
rustBackend.initDataDb()
twig("Initialized wallet for first run")
} catch (e: Throwable) {
@ -95,30 +87,27 @@ class Wallet private constructor(
}
try {
val birthday = loadBirthdayFromAssets(appContext, birthdayHeight)
lowerBoundHeight = birthday.height
rustBackend.initBlocksTable(
birthday.height,
birthday.hash,
birthday.time,
birthday.tree
)
twig("seeded the database with sapling tree at height ${birthday.height}")
twig("seeded the database with sapling tree at height ${birthday.height} (expected $birthdayHeight)")
} catch (t: Throwable) {
if (t.message?.contains("is not empty") == true) {
throw WalletException.AlreadyInitializedException(t)
return null
} else {
throw WalletException.FalseStart(t)
}
}
try {
// store the spendingkey by leveraging the utilities provided during construction
val seed by seedProvider
val accountSpendingKeys =
rustBackend.initAccountsTable(seed, 1)
spendingKeyStore = accountSpendingKeys[0]
twig("Initialized the accounts table")
return Math.max(firstRunStartHeight, birthday.height)
return rustBackend.initAccountsTable(seed, numberOfAccounts).also {
twig("Initialized the accounts table with $numberOfAccounts account(s)")
}
} catch (e: Throwable) {
throw WalletException.FalseStart(e)
}
@ -127,32 +116,32 @@ class Wallet private constructor(
/**
* Gets the address for this wallet, defaulting to the first account.
*/
fun getAddress(accountId: Int = accountIds[0]): String {
return rustBackend.getAddress(accountId)
fun getAddress(accountIndex: Int = 0): String {
return rustBackend.getAddress(accountIndex)
}
/**
* Return a quick snapshot of the available balance. In most cases, the stream of balances
* provided by [balances] should be used instead of this funciton.
*
* @param accountId the account to check for balance info. Defaults to zero.
* @param accountIndex the account to check for balance info. Defaults to zero.
*/
fun availableBalanceSnapshot(accountId: Int = accountIds[0]): Long {
return rustBackend.getVerifiedBalance(accountId)
fun availableBalanceSnapshot(accountIndex: Int = 0): Long {
return rustBackend.getVerifiedBalance(accountIndex)
}
/**
* Calculates the latest balance info and emits it into the balance channel. Defaults to the
* first account.
*
* @param accountId the account to check for balance info.
* @param accountIndex the account to check for balance info.
*/
suspend fun getBalanceInfo(accountId: Int = accountIds[0]): WalletBalance = withContext(IO) {
suspend fun getBalanceInfo(accountIndex: Int = 0): WalletBalance = withContext(IO) {
twigTask("checking balance info") {
try {
val balanceTotal = rustBackend.getBalance(accountId)
val balanceTotal = rustBackend.getBalance(accountIndex)
twig("found total balance of: $balanceTotal")
val balanceAvailable = rustBackend.getVerifiedBalance(accountId)
val balanceAvailable = rustBackend.getVerifiedBalance(accountIndex)
twig("found available balance of: $balanceAvailable")
WalletBalance(balanceTotal, balanceAvailable)
} catch (t: Throwable) {
@ -174,19 +163,22 @@ class Wallet private constructor(
* or -1 if it failed
*/
suspend fun createSpend(
spendingKey: String,
value: Long,
toAddress: String,
memo: String = "",
fromAccountId: Int = accountIds[0]
fromAccountIndex: Int = 0
): Long = withContext(IO) {
twigTask("creating transaction to spend $value zatoshi to" +
" ${toAddress.masked()} with memo $memo") {
twigTask(
"creating transaction to spend $value zatoshi to" +
" ${toAddress.masked()} with memo $memo"
) {
try {
ensureParams(paramDestinationDir)
twig("params exist at $paramDestinationDir! attempting to send...")
ensureParams((rustBackend as RustBackend).paramDestinationDir)
twig("params exist! attempting to send...")
rustBackend.createToAddress(
fromAccountId,
spendingKeyStore,
fromAccountIndex,
spendingKey,
toAddress,
value,
memo
@ -277,9 +269,6 @@ class Wallet private constructor(
return OkHttpClient()
}
private fun String.toPath(): String = "$paramDestinationDir/$this"
companion object {
/**
* The Url that is used by default in zcashd.
@ -306,13 +295,18 @@ class Wallet private constructor(
*/
fun loadBirthdayFromAssets(context: Context, birthdayHeight: Int? = null): WalletBirthday {
val treeFiles =
context.assets.list(Wallet.BIRTHDAY_DIRECTORY)?.apply { sortDescending() }
context.assets.list(BIRTHDAY_DIRECTORY)?.apply { sortDescending() }
if (treeFiles.isNullOrEmpty()) throw MissingBirthdayFilesException(BIRTHDAY_DIRECTORY)
val file: String
try {
val file = treeFiles.first {
file = treeFiles.first() {
if (birthdayHeight == null) true
else it.contains(birthdayHeight.toString())
}
} catch (t: Throwable) {
throw BirthdayNotFoundException(BIRTHDAY_DIRECTORY, birthdayHeight)
}
try {
val reader = JsonReader(
InputStreamReader(context.assets.open("$BIRTHDAY_DIRECTORY/$file"))
)
@ -374,4 +368,19 @@ class Wallet private constructor(
val available: Long = -1
)
//
// Key Management Interfaces
//
interface KeyManager: SeedProvider, SpendingKeyStore, SpendingKeyProvider
interface SeedProvider {
val seed: ByteArray
}
interface SpendingKeyStore {
var key: String
}
interface SpendingKeyProvider {
val key: String
}
}

View File

@ -59,6 +59,9 @@ class LightWalletGrpcService private constructor(
return channel.createStub().sendTransaction(request)
}
override fun shutdown() {
channel.shutdownNow()
}
//
// Utilities

View File

@ -25,4 +25,9 @@ interface LightWalletService {
* Submit a raw transaction.
*/
fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse
/**
* Cleanup any connections when the service is shutting down and not going to be used again.
*/
fun shutdown()
}