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:
parent
ef58fecbf1
commit
17be0c85cb
|
@ -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}"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"height": 280000,
|
||||
"time": 1535262293,
|
||||
"tree": "000000"
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"height": 421720,
|
||||
"time": 1550762014,
|
||||
"tree": "015495a30aef9e18b9c774df6a9fcd583748c8bba1a6348e70f59bc9f0c2bc673b000f00000000018054b75173b577dc36f2c80dfc41f83d6716557597f74ec54436df32d4466d57000120f1825067a52ca973b07431199d5866a0d46ef231d08aa2f544665936d5b4520168d782e3d028131f59e9296c75de5a101898c5e53108e45baa223c608d6c3d3d01fb0a8d465b57c15d793c742df9470b116ddf06bd30d42123fdb7becef1fd63640001a86b141bdb55fd5f5b2e880ea4e07caf2bbf1ac7b52a9f504977913068a917270001dd960b6c11b157d1626f0768ec099af9385aea3f31c91111a8c5b899ffb99e6b0192acd61b1853311b0bf166057ca433e231c93ab5988844a09a91c113ebc58e18019fbfd76ad6d98cafa0174391546e7022afe62e870e20e16d57c4c419a5c2bb69"
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
}
|
|
@ -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 = ""
|
||||
)
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue