diff --git a/build.gradle b/build.gradle index e1e79e8b..4729e34c 100644 --- a/build.gradle +++ b/build.gradle @@ -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}" } diff --git a/src/androidTest/java/cash/z/wallet/sdk/secure/WalletTest.kt b/src/androidTest/java/cash/z/wallet/sdk/secure/WalletTest.kt new file mode 100644 index 00000000..04514104 --- /dev/null +++ b/src/androidTest/java/cash/z/wallet/sdk/secure/WalletTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/src/main/assets/zcash/saplingtree/280000.json b/src/main/assets/zcash/saplingtree/280000.json new file mode 100644 index 00000000..d79886dd --- /dev/null +++ b/src/main/assets/zcash/saplingtree/280000.json @@ -0,0 +1,5 @@ +{ + "height": 280000, + "time": 1535262293, + "tree": "000000" +} \ No newline at end of file diff --git a/src/main/assets/zcash/saplingtree/421720.json b/src/main/assets/zcash/saplingtree/421720.json new file mode 100644 index 00000000..b6b52d54 --- /dev/null +++ b/src/main/assets/zcash/saplingtree/421720.json @@ -0,0 +1,5 @@ +{ + "height": 421720, + "time": 1550762014, + "tree": "015495a30aef9e18b9c774df6a9fcd583748c8bba1a6348e70f59bc9f0c2bc673b000f00000000018054b75173b577dc36f2c80dfc41f83d6716557597f74ec54436df32d4466d57000120f1825067a52ca973b07431199d5866a0d46ef231d08aa2f544665936d5b4520168d782e3d028131f59e9296c75de5a101898c5e53108e45baa223c608d6c3d3d01fb0a8d465b57c15d793c742df9470b116ddf06bd30d42123fdb7becef1fd63640001a86b141bdb55fd5f5b2e880ea4e07caf2bbf1ac7b52a9f504977913068a917270001dd960b6c11b157d1626f0768ec099af9385aea3f31c91111a8c5b899ffb99e6b0192acd61b1853311b0bf166057ca433e231c93ab5988844a09a91c113ebc58e18019fbfd76ad6d98cafa0174391546e7022afe62e870e20e16d57c4c419a5c2bb69" +} \ No newline at end of file diff --git a/src/main/java/cash/z/wallet/sdk/data/CompactBlockProcessor.kt b/src/main/java/cash/z/wallet/sdk/data/CompactBlockProcessor.kt index 21c50a0e..3782e60e 100644 --- a/src/main/java/cash/z/wallet/sdk/data/CompactBlockProcessor.kt +++ b/src/main/java/cash/z/wallet/sdk/data/CompactBlockProcessor.kt @@ -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 } } diff --git a/src/main/java/cash/z/wallet/sdk/data/PollingTransactionRepository.kt b/src/main/java/cash/z/wallet/sdk/data/PollingTransactionRepository.kt index ca24afd8..7f14322e 100644 --- a/src/main/java/cash/z/wallet/sdk/data/PollingTransactionRepository.kt +++ b/src/main/java/cash/z/wallet/sdk/data/PollingTransactionRepository.kt @@ -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) diff --git a/src/main/java/cash/z/wallet/sdk/data/SdkSynchronizer.kt b/src/main/java/cash/z/wallet/sdk/data/SdkSynchronizer.kt index 4eea534f..9bd990a9 100644 --- a/src/main/java/cash/z/wallet/sdk/data/SdkSynchronizer.kt +++ b/src/main/java/cash/z/wallet/sdk/data/SdkSynchronizer.kt @@ -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} ${t.cause?.cause} ${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 { diff --git a/src/main/java/cash/z/wallet/sdk/data/TransactionRepository.kt b/src/main/java/cash/z/wallet/sdk/data/TransactionRepository.kt index 7e9de8bc..544d4e50 100644 --- a/src/main/java/cash/z/wallet/sdk/data/TransactionRepository.kt +++ b/src/main/java/cash/z/wallet/sdk/data/TransactionRepository.kt @@ -11,6 +11,7 @@ interface TransactionRepository { fun balance(): ReceiveChannel fun allTransactions(): ReceiveChannel> fun lastScannedHeight(): Int + fun isInitialized(): Boolean suspend fun findTransactionById(txId: Long): Transaction? suspend fun deleteTransactionById(txId: Long) } \ No newline at end of file diff --git a/src/main/java/cash/z/wallet/sdk/exception/Exceptions.kt b/src/main/java/cash/z/wallet/sdk/exception/Exceptions.kt index 5363932e..25226e45 100644 --- a/src/main/java/cash/z/wallet/sdk/exception/Exceptions.kt +++ b/src/main/java/cash/z/wallet/sdk/exception/Exceptions.kt @@ -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" + ) } \ No newline at end of file diff --git a/src/main/java/cash/z/wallet/sdk/secure/Wallet.kt b/src/main/java/cash/z/wallet/sdk/secure/Wallet.kt index f7b5254f..f0318f29 100644 --- a/src/main/java/cash/z/wallet/sdk/secure/Wallet.kt +++ b/src/main/java/cash/z/wallet/sdk/secure/Wallet.kt @@ -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, spendingKeyProvider: ReadWriteProperty ) { + constructor( + context: Context, + converter: JniConverter, + dbDataPath: String, + paramDestinationDir: String, + accountIds: Array = arrayOf(0), + seedProvider: ReadOnlyProperty, + spendingKeyProvider: ReadWriteProperty + ) : 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 = "" + ) + }