Improve logic and behavior during app startup.

Never start downloading blocks prior to sapling activation height, only allow firstrun when dataDb is empty and also add birthday support. Also added corrections to isFirstRun logic.
This commit is contained in:
Kevin Gorham 2019-02-24 14:18:46 -05:00
parent ef58fecbf1
commit 17be0c85cb
10 changed files with 145 additions and 25 deletions

View File

@ -166,6 +166,7 @@ dependencies {
androidTestImplementation 'org.mockito:mockito-android:2.24.0'
androidTestImplementation "androidx.test:runner:1.1.1"
androidTestImplementation "androidx.test:core:1.1.0"
androidTestImplementation "androidx.arch.core:core-testing:2.0.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.0'
androidTestImplementation "androidx.arch.core:core-testing:${versions.architectureComponents}"
}

View File

@ -0,0 +1,20 @@
package cash.z.wallet.sdk.db
import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.wallet.sdk.secure.Wallet
import org.junit.Assert.assertEquals
import org.junit.Test
class WalletTest {
val context: Context = InstrumentationRegistry.getInstrumentation().context
@Test
fun testLoadDefaultWallet() {
val birthday = Wallet.loadBirthdayFromAssets(context, 280000)
assertEquals("Invalid tree", "000000", birthday.tree)
assertEquals("Invalid height", 280000, birthday.height)
assertEquals("Invalid time", 1535262293, birthday.time)
}
}

View File

@ -0,0 +1,5 @@
{
"height": 280000,
"time": 1535262293,
"tree": "000000"
}

View File

@ -0,0 +1,5 @@
{
"height": 421720,
"time": 1550762014,
"tree": "015495a30aef9e18b9c774df6a9fcd583748c8bba1a6348e70f59bc9f0c2bc673b000f00000000018054b75173b577dc36f2c80dfc41f83d6716557597f74ec54436df32d4466d57000120f1825067a52ca973b07431199d5866a0d46ef231d08aa2f544665936d5b4520168d782e3d028131f59e9296c75de5a101898c5e53108e45baa223c608d6c3d3d01fb0a8d465b57c15d793c742df9470b116ddf06bd30d42123fdb7becef1fd63640001a86b141bdb55fd5f5b2e880ea4e07caf2bbf1ac7b52a9f504977913068a917270001dd960b6c11b157d1626f0768ec099af9385aea3f31c91111a8c5b899ffb99e6b0192acd61b1853311b0bf166057ca433e231c93ab5988844a09a91c113ebc58e18019fbfd76ad6d98cafa0174391546e7022afe62e870e20e16d57c4c419a5c2bb69"
}

View File

@ -13,8 +13,6 @@ import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
/**
* Responsible for processing the blocks on the stream. Saves them to the cacheDb and periodically scans for transactions.
@ -24,8 +22,8 @@ import kotlin.properties.ReadWriteProperty
class CompactBlockProcessor(
applicationContext: Context,
val converter: JniConverter = JniConverter(),
cacheDbName: String = CACHE_DB_NAME,
dataDbName: String = DATA_DB_NAME,
cacheDbName: String = DEFAULT_CACHE_DB_NAME,
dataDbName: String = DEFAULT_DATA_DB_NAME,
logger: Twig = SilentTwig()
) : Twig by logger {
@ -100,17 +98,23 @@ class CompactBlockProcessor(
}
}
/**
* Returns the height of the last processed block or -1 if no blocks have been processed.
*/
suspend fun lastProcessedBlock(): Int = withContext(IO) {
// TODO: maybe start at the tip and keep going backward until we find a verifiably non-corrupted block, far enough back to be immune to reorgs
Math.max(0, cacheDao.latestBlockHeight() - 20)
val lastBlock = Math.max(0, cacheDao.latestBlockHeight() - 1)
if (lastBlock < SAPLING_ACTIVATION_HEIGHT) -1 else lastBlock
}
companion object {
const val DEFAULT_CACHE_DB_NAME = "DownloadedCompactBlocks.db"
const val DEFAULT_DATA_DB_NAME = "CompactBlockScanResults.db"
/** Default amount of time to synchronize before initiating the first scan. This allows time to download a few blocks. */
const val INITIAL_SCAN_DELAY = 3000L
/** Minimum amount of time between scans. The frequency with which we check whether the block height has changed and, if so, trigger a scan */
const val SCAN_FREQUENCY = 75_000L
const val CACHE_DB_NAME = "DownloadedCompactBlocks.db"
const val DATA_DB_NAME = "CompactBlockScanResults.db"
// TODO: find a better home for this constant
const val SAPLING_ACTIVATION_HEIGHT = 280_000
}
}

View File

@ -97,6 +97,10 @@ open class PollingTransactionRepository(
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)

View File

@ -27,6 +27,7 @@ class SdkSynchronizer(
) : Synchronizer {
private lateinit var blockJob: Job
private lateinit var initialState: SyncState
private val wasPreviouslyStarted
get() = ::blockJob.isInitialized
@ -82,8 +83,7 @@ class SdkSynchronizer(
}
override suspend fun isFirstRun(): Boolean = withContext(IO) {
// maybe just toggle a flag somewhere rather than inferring based on db status
!processor.dataDbExists && (!processor.cachDbExists || processor.cacheDao.count() == 0)
initialState is FirstRun
}
/* Operations */
@ -127,11 +127,19 @@ class SdkSynchronizer(
try {
// TODO: for PIR concerns, introduce some jitter here for where, exactly, the downloader starts
val blockChannel =
downloader.start(this, syncState.startingBlockHeight, batchSize, pollFrequencyMillis = blockPollFrequency)
downloader.start(
this,
syncState.startingBlockHeight,
batchSize,
pollFrequencyMillis = blockPollFrequency
)
launch { monitorProgress(downloader.progress()) }
activeTransactionManager.start()
repository.start(this)
processor.processBlocks(blockChannel)
} catch(t:Throwable) {
// TODO: find the best mechanism for error handling
twig("catching an error $t caused by ${t.cause} <and> ${t.cause?.cause} <and> ${t.cause?.cause?.cause} ")
} finally {
stop()
}
@ -157,11 +165,13 @@ class SdkSynchronizer(
//TODO: add state for never scanned . . . where we have some cache but no entries in the data db
private suspend fun determineState(): SyncState = withContext(IO) {
twig("determining state (has the app run before, what block did we last see, etc.)")
val state = if (processor.dataDbExists) {
initialState = if (processor.dataDbExists) {
val isInitialized = repository.isInitialized()
// this call blocks because it does IO
val startingBlockHeight = processor.lastProcessedBlock()
twig("cacheDb exists with last height of $startingBlockHeight")
if (startingBlockHeight <= 0) FirstRun else ReadyToProcess(startingBlockHeight)
val startingBlockHeight = Math.max(processor.lastProcessedBlock(), repository.lastScannedHeight())
twig("cacheDb exists with last height of $startingBlockHeight and isInitialized = $isInitialized")
if (!repository.isInitialized()) FirstRun else ReadyToProcess(startingBlockHeight)
} else if(processor.cachDbExists) {
// this call blocks because it does IO
val startingBlockHeight = processor.lastProcessedBlock()
@ -171,8 +181,8 @@ class SdkSynchronizer(
FirstRun
}
twig("determined ${state::class.java.simpleName}")
state
twig("determined ${initialState::class.java.simpleName}")
initialState
}
sealed class SyncState {

View File

@ -11,6 +11,7 @@ interface TransactionRepository {
fun balance(): ReceiveChannel<Long>
fun allTransactions(): ReceiveChannel<List<WalletTransaction>>
fun lastScannedHeight(): Int
fun isInitialized(): Boolean
suspend fun findTransactionById(txId: Long): Transaction?
suspend fun deleteTransactionById(txId: Long)
}

View File

@ -34,7 +34,15 @@ sealed class CompactBlockStreamException(message: String, cause: Throwable? = nu
}
sealed class WalletException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
object MissingParamsException : WalletException("Cannot send funds due to missing spend or output params and " +
"attempting to download them failed.")
class MissingBirthdayFilesException(directory: String) : WalletException(
"Cannot initialize wallet because no birthday files were found in the $directory directory."
)
class FetchParamsException(message: String) : WalletException("Failed to fetch params due to: $message")
object MissingParamsException : WalletException(
"Cannot send funds due to missing spend or output params and attempting to download them failed."
)
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

@ -1,15 +1,22 @@
package cash.z.wallet.sdk.secure
import cash.z.wallet.sdk.data.*
import android.content.Context
import cash.z.wallet.sdk.data.Bush
import cash.z.wallet.sdk.data.CompactBlockProcessor.Companion.SAPLING_ACTIVATION_HEIGHT
import cash.z.wallet.sdk.data.twig
import cash.z.wallet.sdk.data.twigTask
import cash.z.wallet.sdk.exception.WalletException
import cash.z.wallet.sdk.ext.masked
import cash.z.wallet.sdk.jni.JniConverter
import com.google.gson.Gson
import com.google.gson.stream.JsonReader
import com.squareup.okhttp.OkHttpClient
import com.squareup.okhttp.Request
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
import okio.Okio
import java.io.File
import java.io.InputStreamReader
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
@ -19,6 +26,7 @@ import kotlin.properties.ReadWriteProperty
* required to exercise those abilities.
*/
class Wallet(
private val birthday: WalletBirthday,
private val converter: JniConverter,
private val dbDataPath: String,
private val paramDestinationDir: String,
@ -27,6 +35,24 @@ class Wallet(
private val seedProvider: ReadOnlyProperty<Any?, ByteArray>,
spendingKeyProvider: ReadWriteProperty<Any?, String>
) {
constructor(
context: Context,
converter: JniConverter,
dbDataPath: String,
paramDestinationDir: String,
accountIds: Array<Int> = arrayOf(0),
seedProvider: ReadOnlyProperty<Any?, ByteArray>,
spendingKeyProvider: ReadWriteProperty<Any?, String>
) : this(
birthday = loadBirthdayFromAssets(context),
converter = converter,
dbDataPath = dbDataPath,
paramDestinationDir = paramDestinationDir,
accountIds = accountIds,
seedProvider = seedProvider,
spendingKeyProvider = spendingKeyProvider
)
var spendingKeyStore by spendingKeyProvider
init {
@ -38,11 +64,13 @@ class Wallet(
// get back an array of spending keys for each account. store them super securely
}
fun initialize(firstRunStartHeight: Int = 280000): Int {
fun initialize(
firstRunStartHeight: Int = SAPLING_ACTIVATION_HEIGHT
): Int {
twig("Initializing wallet for first run")
converter.initDataDb(dbDataPath)
//TODO: pass this into the synchronizer and leverage it here
converter.initBlocksTable(dbDataPath, 421720, 1550762014, "015495a30aef9e18b9c774df6a9fcd583748c8bba1a6348e70f59bc9f0c2bc673b000f00000000018054b75173b577dc36f2c80dfc41f83d6716557597f74ec54436df32d4466d57000120f1825067a52ca973b07431199d5866a0d46ef231d08aa2f544665936d5b4520168d782e3d028131f59e9296c75de5a101898c5e53108e45baa223c608d6c3d3d01fb0a8d465b57c15d793c742df9470b116ddf06bd30d42123fdb7becef1fd63640001a86b141bdb55fd5f5b2e880ea4e07caf2bbf1ac7b52a9f504977913068a917270001dd960b6c11b157d1626f0768ec099af9385aea3f31c91111a8c5b899ffb99e6b0192acd61b1853311b0bf166057ca433e231c93ab5988844a09a91c113ebc58e18019fbfd76ad6d98cafa0174391546e7022afe62e870e20e16d57c4c419a5c2bb69")
twig("seeding the database with sapling tree at height ${birthday.height}")
converter.initBlocksTable(dbDataPath, birthday.height, birthday.time, birthday.tree)
// securely store the spendingkey by leveraging the utilities provided during construction
val seed by seedProvider
@ -53,8 +81,7 @@ class Wallet(
// TODO: init blocks table with sapling tree. probably read a table row in and then write it out to disk in a way where we can deserialize easily
// TODO: then use that to determine firstRunStartHeight
// val firstRunStartHeight = 405410
return 421720
return Math.max(firstRunStartHeight, birthday.height)
}
fun getAddress(accountId: Int = accountIds[0]): String {
@ -171,5 +198,40 @@ class Wallet(
const val CLOUD_PARAM_DIR_URL = "https://z.cash/downloads/"
const val SPEND_PARAM_FILE_NAME = "sapling-spend.params"
const val OUTPUT_PARAM_FILE_NAME = "sapling-output.params"
const val BIRTHDAY_DIRECTORY = "zcash/saplingtree"
/**
* Load the given birthday file from the assets of the given context. When no height is specified, we default to
* the file with the greatest name.
*
* @param context the context from which to load assets.
* @param birthdayHeight the height file to look for among the file names.
*
* @return a WalletBirthday that reflects the contents of the file or an exception when parsing fails.
*/
fun loadBirthdayFromAssets(context: Context, birthdayHeight: Int? = null): WalletBirthday {
val treeFiles = context.assets.list(Wallet.BIRTHDAY_DIRECTORY).apply { sortDescending() }
if (treeFiles.isEmpty()) throw WalletException.MissingBirthdayFilesException(BIRTHDAY_DIRECTORY)
try {
val file = treeFiles.first {
if (birthdayHeight == null) true
else it.contains(birthdayHeight.toString())
}
val reader =
JsonReader(InputStreamReader(context.assets.open("$BIRTHDAY_DIRECTORY/$file")))
return Gson().fromJson(reader, WalletBirthday::class.java)
} catch (t: Throwable) {
throw WalletException.MalformattedBirthdayFilesException(BIRTHDAY_DIRECTORY, treeFiles[0])
}
}
}
data class WalletBirthday(
val height: Int = -1,
val time: Long = -1,
val tree: String = ""
)
}