Merge pull request #165 from zcash/feature/merge-initializers

Sprint 37 changes
This commit is contained in:
Kevin Gorham 2020-09-25 10:02:19 -04:00 committed by GitHub
commit 6042eefc3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 758 additions and 988 deletions

5
.gitignore vendored
View File

@ -69,5 +69,10 @@ fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Rust / Cargo
fraget/
# other
DecompileChecker.kt
backup-dbs/

View File

@ -239,9 +239,6 @@ dependencies {
// per this recommendation from Chris Povirk, given guava's decision to split ListenableFuture away from Guava: https://groups.google.com/d/msg/guava-discuss/GghaKwusjcY/bCIAKfzOEwAJ
implementation 'com.google.guava:guava:27.0.1-android'
// Other
implementation "com.jakewharton.timber:timber:4.7.1"
// Tests
testImplementation 'androidx.multidex:multidex:2.0.1'
testImplementation "org.jetbrains.kotlin:kotlin-reflect:${Deps.kotlinVersion}"

View File

@ -3,11 +3,11 @@ package cash.z.ecc.android
object Deps {
// For use in the top-level build.gradle which gives an error when provided
// `Deps.Kotlin.version` directly
const val kotlinVersion = "1.4.0"
const val kotlinVersion = "1.4.10"
const val group = "cash.z.ecc.android"
const val artifactName = "zcash-android-sdk"
const val versionName = "1.1.0-beta05"
const val versionCode = 1_01_00_205 // 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.
const val versionName = "1.1.0-beta07"
const val versionCode = 1_01_00_207 // 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.
const val description = "This lightweight SDK connects Android to Zcash. It welds together Rust and Kotlin in a minimal way, allowing third-party Android apps to send and receive shielded transactions easily, securely and privately."
const val githubUrl = "https://github.com/zcash/zcash-android-wallet-sdk"

View File

@ -72,12 +72,18 @@ publishing {
dependencyNode.appendNode('groupId', it.group)
dependencyNode.appendNode('artifactId', it.name)
dependencyNode.appendNode('version', it.version)
if (it.hasProperty('type')) {
dependencyNode.appendNode('type', it.type)
}
if (it.hasProperty('scope')) {
dependencyNode.appendNode('scope', it.scope)
}
if (it.hasProperty('optional') && it.optional) {
dependencyNode.appendNode('optional', 'true')
}
}
// run the 'addDep' closure over each dependency
configurations.compile.allDependencies.each addDep
configurations.implementation.allDependencies.each addDep
}
}
}
@ -96,7 +102,7 @@ bintray {
publish = true
publicDownloadNumbers = true
userOrg = 'ecc-mobile'
labels = ['aar', 'android', 'zcash', 'ecc', 'sdk']
labels = ['aar', 'native', 'android', 'zcash', 'ecc', 'sdk', 'kotlin', 'mobile', 'electric coin company', 'open source', 'crypto', 'cryptocurrency', 'cryptography', 'privacy']
licenses = ['MIT']
vcsUrl = Deps.githubUrl
dryRun = Deps.publishingDryRun

View File

@ -49,35 +49,16 @@ android {
dependencies {
// SDK
zcashmainnetImplementation 'cash.z.ecc.android:zcash-android-sdk-mainnet:1.1.0-beta05'
zcashtestnetImplementation 'cash.z.ecc.android:zcash-android-sdk-testnet:1.1.0-beta05'
zcashmainnetImplementation 'cash.z.ecc.android:zcash-android-sdk-mainnet:1.1.0-beta07'
zcashtestnetImplementation 'cash.z.ecc.android:zcash-android-sdk-testnet:1.1.0-beta07'
// sample mnemonic plugin
implementation 'com.github.zcash:zcash-android-wallet-plugins:1.0.1'
implementation 'cash.z.ecc.android:kotlin-bip39:1.0.0-beta09'
// SDK: grpc
implementation 'io.grpc:grpc-okhttp:1.25.0'
implementation "io.grpc:grpc-android:1.25.0"
implementation 'io.grpc:grpc-protobuf-lite:1.25.0'
implementation 'io.grpc:grpc-stub:1.25.0'
implementation 'javax.annotation:javax.annotation-api:1.3.2'
// SDK: Room
implementation 'androidx.room:room-ktx:2.2.5'
implementation 'androidx.paging:paging-runtime-ktx:2.1.2'
implementation 'com.google.guava:guava:27.0.1-android'
kapt 'androidx.room:room-compiler:2.2.5'
// SDK: Other
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
// Android
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.3.0-alpha07'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha07' // provides lifecycleScope!
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.0'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.0'
implementation "com.google.android.material:material:1.3.0-alpha02"

View File

@ -110,4 +110,8 @@ class GetBlockRangeFragment : BaseDemoFragment<FragmentGetBlockRangeBinding>() {
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentGetBlockRangeBinding =
FragmentGetBlockRangeBinding.inflate(layoutInflater)
override fun onActionButtonClicked() {
super.onActionButtonClicked()
}
}

View File

@ -8,9 +8,9 @@ import androidx.paging.PagedList
import androidx.recyclerview.widget.LinearLayoutManager
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.Initializer
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.VkInitializer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
import cash.z.ecc.android.sdk.demoapp.App
@ -49,9 +49,9 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
val seed = Mnemonics.MnemonicCode(seedPhrase).toSeed()
App.instance.defaultConfig.let { config ->
initializer = VkInitializer(App.instance) {
import(seed, config.birthdayHeight)
server(config.host, config.port)
initializer = Initializer(App.instance) {
it.import(seed, config.birthdayHeight)
it.server(config.host, config.port)
}
address = DerivationTool.deriveShieldedAddress(seed)
}

View File

@ -46,9 +46,8 @@ import kotlinx.coroutines.withContext
*/
class ListUtxosFragment : BaseDemoFragment<FragmentListUtxosBinding>() {
private val config = App.instance.defaultConfig
private val initializer =
Initializer(App.instance, host = config.host, port = config.port, alias = "Demo_Utxos")
private val birthday = WalletBirthdayTool.loadNearest(App.instance, config.birthdayHeight)
private lateinit var seed: ByteArray
private lateinit var initializer: SdkSynchronizer.SdkInitializer
private lateinit var synchronizer: Synchronizer
private lateinit var adapter: UtxoAdapter<ConfirmedTransaction>
private val address: String = "t1RwbKka1CnktvAJ1cSqdn7c6PXWG4tZqgd"
@ -59,6 +58,26 @@ class ListUtxosFragment : BaseDemoFragment<FragmentListUtxosBinding>() {
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentListUtxosBinding =
FragmentListUtxosBinding.inflate(layoutInflater)
/**
* Initialize the required values that would normally live outside the demo but are repeated
* here for completeness so that each demo file can serve as a standalone example.
*/
private fun setup() {
// Use a BIP-39 library to convert a seed phrase into a byte array. Most wallets already
// have the seed stored
seed = Mnemonics.MnemonicCode(sharedViewModel.seedPhrase.value).toSeed()
initializer = Initializer(App.instance) {
it.import(seed, config.birthdayHeight)
it.alias = "Demo_Utxos"
}
synchronizer = Synchronizer(initializer)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setup()
}
fun initUi() {
binding.inputAddress.setText(address)
binding.inputRangeStart.setText(ZcashSdk.SAPLING_ACTIVATION_HEIGHT.toString())
@ -85,7 +104,7 @@ class ListUtxosFragment : BaseDemoFragment<FragmentListUtxosBinding>() {
val txids = lightwalletService?.getTAddressTransactions(addressToUse, startToUse..endToUse)
var delta = now - allStart
updateStatus("found ${txids?.size} transactions in ${delta}ms.", false)
txids?.map {
it.data.apply {
try {
@ -93,7 +112,7 @@ class ListUtxosFragment : BaseDemoFragment<FragmentListUtxosBinding>() {
} catch (t: Throwable) {
twig("failed to decrypt and store transaction due to: $t")
}
}
}
}?.let { txData ->
val parseStart = now
val tList = LocalRpcTypes.TransactionDataList.newBuilder().addAllData(txData).build()
@ -147,18 +166,6 @@ class ListUtxosFragment : BaseDemoFragment<FragmentListUtxosBinding>() {
var finalCount: Int = 0
fun resetInBackground() {
try {
initializer.new(Mnemonics.MnemonicCode(sharedViewModel.seedPhrase.value).toSeed(), birthday)
} catch (e: Throwable) {
twig("warning to create a new initializer! Trying to open one instead due to: $e")
try {
initializer.open(birthday)
} catch (t: Throwable) {
twig("warning failed to open the initializer. Due to: $t")
}
}
try {
synchronizer = Synchronizer(initializer)
lifecycleScope.launch {
withContext(Dispatchers.IO) {
initialCount = (synchronizer as SdkSynchronizer).getTransactionCount()
@ -179,7 +186,6 @@ class ListUtxosFragment : BaseDemoFragment<FragmentListUtxosBinding>() {
fun onClear() {
synchronizer.stop()
initializer.clear()
}
private fun initTransactionUi() {

View File

@ -7,14 +7,13 @@ import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.Initializer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.VkInitializer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.db.entity.*
import cash.z.ecc.android.sdk.demoapp.App
import cash.z.ecc.android.sdk.demoapp.BaseDemoFragment
import cash.z.ecc.android.sdk.demoapp.databinding.FragmentSendBinding
import cash.z.ecc.android.sdk.demoapp.util.SampleStorageBridge
import cash.z.ecc.android.sdk.demoapp.util.mainActivity
import cash.z.ecc.android.sdk.ext.*
import cash.z.ecc.android.sdk.tool.DerivationTool
@ -51,9 +50,9 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
val seed = Mnemonics.MnemonicCode(seedPhrase).toSeed()
App.instance.defaultConfig.let { config ->
VkInitializer(App.instance) {
import(seed, config.birthdayHeight)
server(config.host, config.port)
Initializer(App.instance) {
it.import(seed, config.birthdayHeight)
it.server(config.host, config.port)
}.let { initializer ->
synchronizer = Synchronizer(initializer)
}

View File

@ -4,7 +4,6 @@
buildscript {
ext.kotlin_version = '1.4.0'
ext.sdk_version = '1.0.0-alpha03'
repositories {
google ()
jcenter()

View File

@ -6,18 +6,17 @@ import cash.z.ecc.android.sdk.ext.Twig
import org.junit.Assert.assertEquals
import org.junit.Test
class VkInitializerTest {
class InitializerTest {
@Test
fun testInit() {
val height = 1_419_900
val initializer = VkInitializer(context) {
importedWalletBirthday(height)
viewingKeys("zxviews1qvn6j50dqqqqpqxqkvqgx2sp63jccr4k5t8zefadpzsu0yy73vczfznwc794xz6lvy3yp5ucv43lww48zz95ey5vhrsq83dqh0ky9junq0cww2wjp9c3cd45n5l5x8l2g9atnx27e9jgyy8zasjy26gugjtefphan9al3tx208m8ekev5kkx3ug6pd0qk4gq4j4wfuxajn388pfpq54wklwktqkyjz9e6gam0n09xjc35ncd3yah5aa9ezj55lk4u7v7hn0v86vz7ygq4qj2v",
val initializer = Initializer(context) { config ->
config.importedWalletBirthday(height)
config.setViewingKeys("zxviews1qvn6j50dqqqqpqxqkvqgx2sp63jccr4k5t8zefadpzsu0yy73vczfznwc794xz6lvy3yp5ucv43lww48zz95ey5vhrsq83dqh0ky9junq0cww2wjp9c3cd45n5l5x8l2g9atnx27e9jgyy8zasjy26gugjtefphan9al3tx208m8ekev5kkx3ug6pd0qk4gq4j4wfuxajn388pfpq54wklwktqkyjz9e6gam0n09xjc35ncd3yah5aa9ezj55lk4u7v7hn0v86vz7ygq4qj2v",
"zxviews1qv886f6hqqqqpqy2ajg9sm22vs4gm4hhajthctfkfws34u45pjtut3qmz0eatpqzvllgsvlk3x0y35ktx5fnzqqzueyph20k3328kx46y3u5xs4750cwuwjuuccfp7la6rh8yt2vjz6tylsrwzy3khtjjzw7etkae6gw3vq608k7quka4nxkeqdxxsr9xxdagv2rhhwugs6w0cquu2ykgzgaln2vyv6ah3ram2h6lrpxuznyczt2xl3lyxcwlk4wfz5rh7wzfd7642c2ae5d7")
alias = "VkInitTest2"
config.alias = "VkInitTest2"
}
assertEquals(height, initializer.birthday.height)
}

View File

@ -1,8 +1,8 @@
package cash.z.ecc.android.sdk.ext
import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.Initializer
import cash.z.ecc.android.sdk.Initializer.DefaultBirthdayStore.Companion.ImportedWalletBirthdayStore
import cash.z.ecc.android.sdk.Initializer.DefaultBirthdayStore.Companion.NewWalletBirthdayStore
import cash.z.ecc.android.sdk.util.SimpleMnemonics
import kotlinx.coroutines.*
import org.junit.After
@ -11,38 +11,18 @@ import org.junit.Before
import org.junit.BeforeClass
import java.util.concurrent.TimeoutException
fun Initializer.importPhrase(
seedPhrase: String,
birthdayHeight: Int,
alias: String = ZcashSdk.DEFAULT_ALIAS,
clearCacheDb: Boolean = true,
clearDataDb: Boolean = true
) {
SimpleMnemonics().toSeed(seedPhrase.toCharArray()).let { seed ->
ImportedWalletBirthdayStore(context, birthdayHeight, alias).getBirthday().let {
import(seed, it, clearCacheDb, clearDataDb)
}
}
}
fun Initializer.new(alias: String = ZcashSdk.DEFAULT_ALIAS) {
SimpleMnemonics().let { mnemonics ->
mnemonics.nextMnemonic().let { phrase ->
twig("DELETE THIS LOG! ${String(phrase)}")
NewWalletBirthdayStore(context, alias).getBirthday().let {
new(mnemonics.toSeed(phrase), it, 1, true, true)
}
}
}
fun Initializer.Builder.seedPhrase(seedPhrase: String) {
setSeed(SimpleMnemonics().toSeed(seedPhrase.toCharArray()))
}
fun Initializer.deriveSpendingKey(seedPhrase: String) =
deriveSpendingKeys(SimpleMnemonics().toSeed(seedPhrase.toCharArray()))[0]
open class ScopedTest(val defaultTimeout: Long = 2000L) {
protected lateinit var testScope: CoroutineScope
// if an androidTest doesn't need a context, then maybe it should be a unit test instead?!
val context: Context = InstrumentationRegistry.getInstrumentation().context
@Before
fun start() {
twig("===================== TEST STARTED ==================================")

View File

@ -0,0 +1,124 @@
package cash.z.ecc.android.sdk.integration.service
import androidx.test.ext.junit.runners.AndroidJUnit4
import cash.z.ecc.android.sdk.block.CompactBlockDownloader
import cash.z.ecc.android.sdk.block.CompactBlockStore
import cash.z.ecc.android.sdk.exception.LightWalletException
import cash.z.ecc.android.sdk.exception.LightWalletException.ChangeServerException.*
import cash.z.ecc.android.sdk.ext.ScopedTest
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.twig
import cash.z.ecc.android.sdk.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.service.LightWalletService
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.Spy
@RunWith(AndroidJUnit4::class)
class ChangeServiceTest : ScopedTest() {
@Mock
lateinit var mockBlockStore: CompactBlockStore
var mockCloseable: AutoCloseable? = null
@Spy
val service = LightWalletGrpcService(context, ZcashSdk.DEFAULT_LIGHTWALLETD_HOST)
lateinit var downloader: CompactBlockDownloader
lateinit var otherService: LightWalletService
@Before
fun setup() {
initMocks()
downloader = CompactBlockDownloader(service, mockBlockStore)
otherService = LightWalletGrpcService(context, "lightwalletd.electriccoin.co", 9067)
}
@After
fun tearDown() {
mockCloseable?.close()
}
private fun initMocks() {
mockCloseable = MockitoAnnotations.openMocks(this)
}
@Test
fun testSanityCheck() {
val result = service.getLatestBlockHeight()
assertTrue(result > ZcashSdk.SAPLING_ACTIVATION_HEIGHT)
}
@Test
fun testCleanSwitch() = runBlocking {
downloader.changeService(otherService)
val result = downloader.downloadBlockRange(900_000..901_000)
assertEquals(1_001, result)
}
@Test
fun testSwitchWhileActive() = runBlocking {
val start = 900_000
val count = 5
val vendors = mutableListOf<String>()
var oldVendor = downloader.getServerInfo().vendor
val job = testScope.launch {
repeat(count) {
vendors.add(downloader.getServerInfo().vendor)
twig("downloading from ${vendors.last()}")
downloader.downloadBlockRange(start..(start + 100 * it))
delay(10L)
}
}
delay(30)
testScope.launch {
downloader.changeService(otherService)
}
job.join()
assertTrue(vendors.count { it == oldVendor } < vendors.size)
assertEquals(count, vendors.size)
}
@Test
fun testSwitchToInvalidServer() = runBlocking {
var caughtException: Throwable? = null
downloader.changeService(LightWalletGrpcService(context, "invalid.lightwalletd")) {
caughtException = it
}
assertNotNull("Using an invalid host should generate an exception.", caughtException)
assertTrue(
"Exception was of the wrong type.",
caughtException is StatusException
)
}
@Test
fun testSwitchToTestnetFails() = runBlocking {
var caughtException: Throwable? = null
downloader.changeService(LightWalletGrpcService(context, "lightwalletd.testnet.electriccoin.co", 9067)) {
caughtException = it
}
assertNotNull("Using an invalid host should generate an exception.", caughtException)
assertTrue(
"Exception was of the wrong type.",
caughtException is ChainInfoNotMatching
)
(caughtException as ChainInfoNotMatching).propertyNames.let { props ->
arrayOf("consensusBranchId", "saplingActivationHeight", "chainName").forEach {
assertTrue(
"$it should be a non-matching property but properties were [$props]", props.contains(it, true)
)
}
}
}
}

View File

@ -35,7 +35,6 @@ class PersistentTransactionManagerTest : ScopedTest() {
val pendingDbName = "PersistentTxMgrTest_Pending.db"
val dataDbName = "PersistentTxMgrTest_Data.db"
private val context = InstrumentationRegistry.getInstrumentation().context
private lateinit var manager: OutboundTransactionManager
@Before

View File

@ -2,7 +2,8 @@ package cash.z.ecc.android.sdk.util
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.Initializer
import cash.z.ecc.android.sdk.Initializer.WalletBirthday
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.tool.WalletBirthdayTool
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
@ -18,7 +19,7 @@ import java.io.IOException
class AddressGeneratorUtil {
private val context = InstrumentationRegistry.getInstrumentation().context
private val initializer = Initializer(context).open(WalletBirthday())
private val mnemonics = SimpleMnemonics()
@Test
@ -36,7 +37,7 @@ class AddressGeneratorUtil {
.map { seedPhrase ->
mnemonics.toSeed(seedPhrase.toCharArray())
}.map { seed ->
initializer.rustBackend.deriveAddress(seed)
DerivationTool.deriveShieldedAddress(seed)
}.collect { address ->
println("xrxrx2\t$address")
assertTrue(address.startsWith("zs1"))
@ -45,7 +46,7 @@ class AddressGeneratorUtil {
@Throws(IOException::class)
fun readLines() = flow<String> {
val seedFile = javaClass.getResourceAsStream("/utils/seeds.txt")
val seedFile = javaClass.getResourceAsStream("/utils/seeds.txt")!!
Okio.buffer(Okio.source(seedFile)).use { source ->
var line: String? = source.readUtf8Line()
while (line != null) {

View File

@ -10,6 +10,7 @@ import cash.z.ecc.android.sdk.ext.TroubleshootingTwig
import cash.z.ecc.android.sdk.ext.Twig
import cash.z.ecc.android.sdk.ext.twig
import cash.z.ecc.android.sdk.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.tool.WalletBirthdayTool
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
@ -37,27 +38,26 @@ class BalancePrinterUtil {
private val mnemonics = SimpleMnemonics()
private val context = InstrumentationRegistry.getInstrumentation().context
private val alias = "BalanceUtil"
private val caceDbPath = Initializer.cacheDbPath(context, alias)
private val downloader = CompactBlockDownloader(
LightWalletGrpcService(context, host, port),
CompactBlockDbStore(context, caceDbPath)
)
// private val caceDbPath = Initializer.cacheDbPath(context, alias)
//
// private val downloader = CompactBlockDownloader(
// LightWalletGrpcService(context, host, port),
// CompactBlockDbStore(context, caceDbPath)
// )
// private val processor = CompactBlockProcessor(downloader)
// private val rustBackend = RustBackend.init(context, cacheDbName, dataDbName)
private val initializer = Initializer(context, host, port, alias)
private lateinit var birthday: Initializer.WalletBirthday
private lateinit var birthday: WalletBirthdayTool.WalletBirthday
private var synchronizer: Synchronizer? = null
@Before
fun setup() {
Twig.plant(TroubleshootingTwig())
cacheBlocks()
birthday = Initializer.DefaultBirthdayStore(context, birthdayHeight, alias).getBirthday()
birthday = WalletBirthdayTool.loadNearest(context, birthdayHeight)
}
private fun cacheBlocks() = runBlocking {
@ -82,7 +82,12 @@ class BalancePrinterUtil {
twig("checking balance for: $seedPhrase")
mnemonics.toSeed(seedPhrase.toCharArray())
}.collect { seed ->
initializer.import(seed, birthday, clearDataDb = true, clearCacheDb = false)
// TODO: clear the dataDb but leave the cacheDb
val initializer = Initializer(context) { config ->
config.import(seed, birthdayHeight)
config.server(host, port)
config.alias = alias
}
/*
what I need to do right now
- for each seed

View File

@ -6,6 +6,7 @@ import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
import cash.z.ecc.android.sdk.ext.*
import cash.z.ecc.android.sdk.tool.DerivationTool
import io.grpc.StatusRuntimeException
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@ -27,10 +28,10 @@ class DarksideTestCoordinator(val host: String = "127.0.0.1", val testName: Stri
// dependencies: public
val validator = DarksideTestValidator()
val chainMaker = DarksideChainMaker()
var initializer = Initializer(context, host, port, testName)
// var initializer = Initializer(context, Initializer.Builder(host, port, testName))
lateinit var synchronizer: SdkSynchronizer
val spendingKey: String get() = initializer.deriveSpendingKey(seedPhrase)
val spendingKey: String get() = DerivationTool.deriveSpendingKeys(SimpleMnemonics().toSeed(seedPhrase.toCharArray()))[0]
//
// High-level APIs
@ -66,11 +67,11 @@ class DarksideTestCoordinator(val host: String = "127.0.0.1", val testName: Stri
*/
fun initiate() {
twig("*************** INITIALIZING TEST COORDINATOR (ONLY ONCE) ***********************")
initializer.importPhrase(
seedPhrase,
birthdayHeight,
testName
)
val initializer = Initializer(context) { config ->
config.seedPhrase(seedPhrase)
config.birthdayHeight = birthdayHeight
config.alias = testName
}
synchronizer = Synchronizer(initializer) as SdkSynchronizer
val channel = (synchronizer as SdkSynchronizer).channel
darkside = DarksideApi(channel)
@ -307,4 +308,4 @@ class DarksideTestCoordinator(val host: String = "127.0.0.1", val testName: Stri
"https://raw.githubusercontent.com/zcash-hackworks/darksidewalletd-test-data/master/basic-reorg/after-large-reorg.txt"
private const val DEFAULT_START_HEIGHT = 663150
}
}
}

View File

@ -6,6 +6,7 @@ import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.ext.TroubleshootingTwig
import cash.z.ecc.android.sdk.ext.Twig
import cash.z.ecc.android.sdk.tool.WalletBirthdayTool
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@ -37,9 +38,7 @@ class DataDbScannerUtil {
// private val rustBackend = RustBackend.init(context, cacheDbName, dataDbName)
private val initializer = Initializer(context, host, port, alias)
private lateinit var birthday: Initializer.WalletBirthday
private val birthdayHeight = 600_000
private lateinit var synchronizer: Synchronizer
@ -47,7 +46,6 @@ class DataDbScannerUtil {
fun setup() {
Twig.plant(TroubleshootingTwig())
// cacheBlocks()
birthday = Initializer.DefaultBirthdayStore(context, birthdayHeight, alias).getBirthday()
}
private fun cacheBlocks() = runBlocking {
@ -67,8 +65,7 @@ class DataDbScannerUtil {
@Test
fun scanExistingDb() {
initializer.open(birthday)
synchronizer = Synchronizer(initializer)
synchronizer = Synchronizer(Initializer(context) { it.birthdayHeight = birthdayHeight})
println("sync!")
synchronizer.start()

View File

@ -6,9 +6,10 @@ import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.Synchronizer.Status.SYNCED
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
import cash.z.ecc.android.sdk.ext.*
import cash.z.ecc.android.sdk.import
import cash.z.ecc.android.sdk.jni.RustBackend
import cash.z.ecc.android.sdk.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.tool.WalletBirthdayTool
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@ -38,7 +39,7 @@ class IntegrationTest {
@Test
fun testLoadBirthday() {
val (height, hash, time, tree) = Initializer.DefaultBirthdayStore.loadBirthdayFromAssets(context, ZcashSdk.SAPLING_ACTIVATION_HEIGHT + 1)
val (height, hash, time, tree) = WalletBirthdayTool.loadNearest(context, ZcashSdk.SAPLING_ACTIVATION_HEIGHT + 1)
assertEquals(ZcashSdk.SAPLING_ACTIVATION_HEIGHT, height)
}
@ -74,7 +75,7 @@ class IntegrationTest {
}
private suspend fun sendFunds(): Boolean {
val spendingKey = RustBackend().deriveSpendingKeys(seed)[0]
val spendingKey = DerivationTool.deriveSpendingKeys(seed)[0]
log("sending to address")
synchronizer.sendToAddress(
spendingKey,
@ -104,8 +105,10 @@ class IntegrationTest {
val toAddress = "zs1vp7kvlqr4n9gpehztr76lcn6skkss9p8keqs3nv8avkdtjrcctrvmk9a7u494kluv756jeee5k0"
private val context = InstrumentationRegistry.getInstrumentation().context
private val initializer = Initializer(context, host, port).apply {
import(seed, birthdayHeight, overwrite = true)
private val initializer = Initializer(context) { config ->
config.setSeed(seed)
config.server(host, port)
config.birthdayHeight = birthdayHeight
}
private val synchronizer: Synchronizer = Synchronizer(initializer)

View File

@ -1,259 +1,96 @@
package cash.z.ecc.android.sdk
import android.content.Context
import android.content.SharedPreferences
import cash.z.ecc.android.sdk.Initializer.DefaultBirthdayStore.Companion.ImportedWalletBirthdayStore
import cash.z.ecc.android.sdk.block.CompactBlockDbStore
import cash.z.ecc.android.sdk.block.CompactBlockDownloader
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.block.CompactBlockStore
import cash.z.ecc.android.sdk.exception.BirthdayException
import cash.z.ecc.android.sdk.exception.InitializerException
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.tryWarn
import cash.z.ecc.android.sdk.ext.twig
import cash.z.ecc.android.sdk.jni.RustBackend
import cash.z.ecc.android.sdk.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.service.LightWalletService
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.tool.WalletBirthdayTool
import cash.z.ecc.android.sdk.tool.WalletBirthdayTool.WalletBirthday
import cash.z.ecc.android.sdk.transaction.*
import com.google.gson.Gson
import com.google.gson.stream.JsonReader
import java.io.File
import java.io.InputStreamReader
import java.util.*
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* Responsible for initialization, which can be considered as setup that must happen before
* synchronizing begins. This begins with one of three actions, a call to either [new], [import] or
* [open], where the last option is the most common case--when a user is opening a wallet they have
* used before on this device.
*
* @param appContext the application context, used to extract the storage paths for the databases
* and param files. A reference to the context is held beyond initialization in order to simplify
* synchronizer construction so that an initializer is all that is ever required.
* @param host the host that the synchronizer should use.
* @param port the port that the synchronizer should use when connecting to the host.
* @param alias the alias to use for this synchronizer. Think of it as a unique name that allows
* multiple synchronizers to function in the same app. The alias is mapped to database names for the
* cache and data DBs. This value is optional and is usually not required because most apps only
* need one synchronizer.
* Simplified Initializer focused on starting from a ViewingKey.
*/
class Initializer(
appContext: Context,
override val host: String = ZcashSdk.DEFAULT_LIGHTWALLETD_HOST,
override val port: Int = ZcashSdk.DEFAULT_LIGHTWALLETD_PORT,
override val alias: String = ZcashSdk.DEFAULT_ALIAS
) : SdkSynchronizer.SdkInitializer {
class Initializer constructor(appContext: Context, builder: Builder): SdkSynchronizer.SdkInitializer {
override val context = appContext.applicationContext
override val rustBackend: RustBackend
override val alias: String
override val host: String
override val port: Int
val viewingKeys: List<String>
val birthday: WalletBirthdayTool.WalletBirthday
init {
validateAlias(alias)
val loadedBirthday =
builder.birthday ?: WalletBirthdayTool.loadNearest(context, builder.birthdayHeight)
birthday = loadedBirthday
viewingKeys = builder.viewingKeys
alias = builder.alias
host = builder.host
port = builder.port
rustBackend = initRustBackend(birthday)
initMissingDatabases(birthday, *viewingKeys.toTypedArray())
}
constructor(appContext: Context, block: (Builder) -> Unit) : this(appContext, Builder(block))
private fun initRustBackend(birthday: WalletBirthdayTool.WalletBirthday): RustBackend {
return RustBackend.init(
cacheDbPath(context, alias),
dataDbPath(context, alias),
"${context.cacheDir.absolutePath}/params",
birthday.height
)
}
private fun initMissingDatabases(
birthday: WalletBirthdayTool.WalletBirthday,
vararg viewingKeys: String
) {
maybeCreateDataDb()
maybeInitBlocksTable(birthday)
maybeInitAccountsTable(*viewingKeys)
}
/**
* The path this initializer will use when checking for and downloading sapling params. This
* value is derived from the appContext when this class is constructed.
* Create the dataDb and its table, if it doesn't exist.
*/
private val pathParams: String = "${context.cacheDir.absolutePath}/params"
/**
* The path used for storing cached compact blocks for processing.
*/
private val pathCacheDb: String = cacheDbPath(context, alias)
/**
* The path used for storing the data derived from the cached compact blocks.
*/
private val pathDataDb: String = dataDbPath(context, alias)
/**
* Backing field for rustBackend, used for giving better error messages whenever the initializer
* is mistakenly used prior to being properly loaded.
*/
private var _rustBackend: RustBackend? = null
/**
* A wrapped version of [cash.z.ecc.android.sdk.jni.RustBackendWelding] that will be passed to the
* SDK when it is constructed. It provides access to all Librustzcash features and is configured
* based on this initializer.
*/
override val rustBackend: RustBackend get() {
check(_rustBackend != null) {
"Error: RustBackend must be loaded before it is accessed. Verify that either" +
" the 'open', 'new' or 'import' function has been called on the Initializer."
}
return _rustBackend!!
}
/**
* The birthday that was ultimately used for initializing the accounts.
*/
lateinit var birthday: WalletBirthday
/**
* Returns true when either 'open', 'new' or 'import' have already been called. Each of those
* functions calls `initRustLibrary` before returning. The entire point of the initializer is to
* setup everything necessary for the Synchronizer to function, which mainly boils down to
* loading the rust backend.
*/
val isInitialized: Boolean get() = _rustBackend != null
/**
* Initialize a new wallet with the given seed and birthday. It creates the required database
* tables and loads and configures the [rustBackend] property for use by all other components.
*
* @param seed the seed to use for the newly created wallet.
* @param newWalletBirthday the birthday to use for the newly created wallet. Typically, this
* corresponds to the most recent checkpoint available since new wallets should not have any
* transactions prior to their creation.
* @param numberOfAccounts the number of accounts to create for this wallet. This is not fully
* supported so the default value of 1 is recommended.
* @param clearCacheDb when true, this will delete cacheDb, if it exists, resulting in the fresh
* download of all compact blocks. Otherwise, downloading resumes from the last fetched block.
* @param clearDataDb when true, this will delete the dataDb, if it exists, resulting in the
* fresh scan of all blocks. Otherwise, initialization crashes when previous wallet data exists
* to prevent accidental overwrites.
*
* @return the account spending keys, corresponding to the accounts that get initialized in the
* DB.
* @throws InitializerException.AlreadyInitializedException when the blocks table already exists
* and [clearDataDb] is false.
*
* @return the spending key(s) associated with this wallet, for convenience.
*/
fun new(
seed: ByteArray,
newWalletBirthday: WalletBirthday,
numberOfAccounts: Int = 1,
clearCacheDb: Boolean = false,
clearDataDb: Boolean = false
): Array<String> {
return initializeAccounts(seed, newWalletBirthday, numberOfAccounts,
clearCacheDb = clearCacheDb, clearDataDb = clearDataDb)
}
/**
* Initialize a new wallet with the imported seed and birthday. It creates the required database
* tables and loads and configures the [rustBackend] property for use by all other components.
*
* @param seed the seed to use for the imported wallet.
* @param previousWalletBirthday the birthday to use for the imported. Typically, this
* corresponds to the height where this wallet was first created, allowing the wallet to be
* optimized not to download or scan blocks from before the wallet existed.
* @param clearCacheDb when true, this will delete cacheDb, if it exists, resulting in the fresh
* download of all compact blocks. Otherwise, downloading resumes from the last fetched block.
* @param clearDataDb when true, this will delete the dataDb, if it exists, resulting in the
* fresh scan of all blocks. Otherwise, this function throws an exception when previous wallet
* data exists to prevent accidental overwrites.
*
* @return the account spending keys, corresponding to the accounts that get initialized in the
* DB.
* @throws InitializerException.AlreadyInitializedException when the blocks table already exists
* and [clearDataDb] is false.
*
* @return the spending key(s) associated with this wallet, for convenience.
*/
fun import(
seed: ByteArray,
previousWalletBirthday: WalletBirthday,
clearCacheDb: Boolean = false,
clearDataDb: Boolean = false
): Array<String> {
return initializeAccounts(seed, previousWalletBirthday, clearCacheDb = clearCacheDb, clearDataDb = clearDataDb)
}
/**
* Loads the rust library and previously used birthday for use by all other components. This is
* the most common use case for the initializer--reopening a wallet that was previously created.
*
* @param birthday birthday height of the wallet. This value is passed to the
* [CompactBlockProcessor] and becomes a factor in determining the lower bounds height that this
* wallet will use. This height helps with determining where to start downloading as well as how
* far back to go during a rewind. Every wallet has a birthday and the initializer depends on
* this value but does not own it.
*
* @return an instance of this class so that the function can be used fluidly. Spending keys are
* not returned because the SDK does not store them and this function is for opening a wallet
* that was created previously.
*/
fun open(birthday: WalletBirthday): Initializer {
twig("Opening wallet with birthday ${birthday.height}")
requireRustBackend(birthday)
return this
}
/**
* Initializes the databases that the rust library uses for managing state. The "data db" is
* created and a row is entered corresponding to the given birthday so that scanning does not
* need to start from the beginning of time. Lastly, the accounts table is initialized to
* simply hold the address and viewing key for each account, which simplifies the process of
* scanning and decrypting compact blocks.
*
* @param seed the seed to use for initializing accounts. We derive the address and the viewing
* key(s) from this seed and also return the related spending key(s). Only the viewing key is
* retained in the database in order to simplify scanning for the wallet.
* @param birthday the birthday to use for this wallet. This is used in order to seed the data
* DB with the first sapling tree, which also determines where the SDK begins downloading and
* scanning. Any blocks lower than the height represented by this birthday can safely be ignored
* since a wallet cannot have transactions prior to its creation.
* @param numberOfAccounts the number of accounts to create. Only 1 account is tested and
* supported at this time. It is possible, although unlikely that multiple accounts would behave
* as expected. Due to the nature of shielded address, the official Zcash recommendation is to
* only use one address for shielded transactions. Unlike transparent coins, address rotation is
* not necessary for shielded Zcash transactions because the sensitive information is private.
* @param clearCacheDb when true, the cache DB will be deleted prior to initializing accounts.
* This is useful for preventing errors when the database already exists, which happens often
* in tests, demos and proof of concepts.
* @param clearDataDb when true, the cache DB will be deleted prior to initializing accounts.
* This is useful for preventing errors when the database already exists, which happens often
* in tests, demos and proof of concepts.
*
* @return the spending keys for each account, ordered by index. These keys are only needed for
* spending funds.
*/
private fun initializeAccounts(
seed: ByteArray,
birthday: WalletBirthday,
numberOfAccounts: Int = 1,
clearCacheDb: Boolean = false,
clearDataDb: Boolean = false
): Array<String> {
this.birthday = birthday
twig("Initializing accounts with birthday ${birthday.height}")
try {
requireRustBackend(birthday).clear(clearCacheDb, clearDataDb)
// only creates tables, if they don't exist
requireRustBackend(birthday).initDataDb()
private fun maybeCreateDataDb() {
tryWarn("Warning: did not create dataDb. It probably already exists.") {
rustBackend.initDataDb()
twig("Initialized wallet for first run")
} catch (t: Throwable) {
throw InitializerException.FalseStart(t)
}
}
try {
requireRustBackend(birthday).initBlocksTable(
/**
* Initialize the blocks table with the given birthday, if needed.
*/
private fun maybeInitBlocksTable(birthday: WalletBirthdayTool.WalletBirthday) {
tryWarn(
"Warning: did not initialize the blocks table. It probably was already initialized."
) {
rustBackend.initBlocksTable(
birthday.height,
birthday.hash,
birthday.time,
birthday.tree
)
twig("seeded the database with sapling tree at height ${birthday.height}")
} catch (t: Throwable) {
if (t.message?.contains("is not empty") == true) {
throw InitializerException.AlreadyInitializedException(t, rustBackend.pathDataDb)
} else {
throw InitializerException.FalseStart(t)
}
}
}
try {
return requireRustBackend(birthday).initAccountsTable(seed, numberOfAccounts).also {
twig("Initialized the accounts table with ${numberOfAccounts} account(s)")
}
} catch (t: Throwable) {
throw InitializerException.FalseStart(t)
/**
* Initialize the accounts table with the given viewing keys, if needed.
*/
private fun maybeInitAccountsTable(vararg viewingKeys: String) {
tryWarn(
"Warning: did not initialize the accounts table. It probably was already initialized."
) {
rustBackend.initAccountsTable(*viewingKeys)
twig("Initialized the accounts table with ${viewingKeys.size} viewingKey(s)")
}
}
@ -265,328 +102,247 @@ class Initializer(
rustBackend.clear()
}
//
// Path Helpers
//
/**
* Internal function used to initialize the [rustBackend] before use. Initialization should only
* happen as a result of [new], [import] or [open] being called or as part of stand-alone key
* derivation. This involves loading the shared object file via `System.loadLibrary`.
* Returns the path to the cache database that would correspond to the given alias.
*
* @return the rustBackend that was loaded by this initializer.
* @param appContext the application context
* @param alias the alias to convert into a database path
*/
private fun requireRustBackend(walletBirthday: WalletBirthday? = null): RustBackend {
if (!isInitialized) {
twig("Initializing cache: $pathCacheDb data: $pathDataDb params: $pathParams")
_rustBackend = RustBackend().init(pathCacheDb, pathDataDb, pathParams, walletBirthday?.height)
}
return rustBackend
}
companion object {
//
// Path Helpers
//
/**
* Returns the path to the cache database that would correspond to the given alias.
*
* @param appContext the application context
* @param alias the alias to convert into a database path
*/
fun cacheDbPath(appContext: Context, alias: String): String =
aliasToPath(appContext, alias, ZcashSdk.DB_CACHE_NAME)
/**
* Returns the path to the data database that would correspond to the given alias.
* @param appContext the application context
* @param alias the alias to convert into a database path
*/
fun dataDbPath(appContext: Context, alias: String): String =
aliasToPath(appContext, alias, ZcashSdk.DB_DATA_NAME)
private fun aliasToPath(appContext: Context, alias: String, dbFileName: String): String {
val parentDir: String =
appContext.getDatabasePath("unused.db").parentFile?.absolutePath
?: throw InitializerException.DatabasePathException
val prefix = if (alias.endsWith('_')) alias else "${alias}_"
return File(parentDir, "$prefix$dbFileName").absolutePath
}
}
fun cacheDbPath(appContext: Context, alias: String): String =
aliasToPath(appContext, alias, ZcashSdk.DB_CACHE_NAME)
/**
* Interface for classes that can handle birthday storage. This makes it possible to bridge into
* existing storage logic. Instances of this interface can also be used as property delegates,
* which enables the syntax `val birthday by birthdayStore`
* Returns the path to the data database that would correspond to the given alias.
* @param appContext the application context
* @param alias the alias to convert into a database path
*/
interface WalletBirthdayStore : ReadWriteProperty<R, WalletBirthday> {
val newWalletBirthday: WalletBirthday
/**
* Get the birthday of the wallet, saved in this store.
*/
fun getBirthday(): WalletBirthday
/**
* Set the birthday of the wallet to be saved in this store.
*/
fun setBirthday(value: WalletBirthday)
/**
* Load a birthday matching the given height. This is most commonly used during import to
* find the first available checkpoint that is lower than the requested height.
*
* @param birthdayHeight the height to use as an upper bound for loading.
*/
fun loadBirthday(birthdayHeight: Int): WalletBirthday
/**
* Return true when a birthday has been stored in this instance.
*/
fun hasExistingBirthday(): Boolean
/**
* Return true when a birthday was imported into this instance.
*/
fun hasImportedBirthday(): Boolean
/* Property implementation that allows this interface to be used as a property delegate */
/**
* Implement readable interface in order to be able to use instances of this interface as
* property delegates.
*/
override fun getValue(thisRef: R, property: KProperty<*>): WalletBirthday {
return getBirthday()
}
/**
* Implement writable interface in order to be able to use instances of this interface as
* property delegates.
*/
override fun setValue(thisRef: R, property: KProperty<*>, value: WalletBirthday) {
setBirthday(value)
}
fun dataDbPath(appContext: Context, alias: String): String =
aliasToPath(appContext, alias, ZcashSdk.DB_DATA_NAME)
private fun aliasToPath(appContext: Context, alias: String, dbFileName: String): String {
val parentDir: String =
appContext.getDatabasePath("unused.db").parentFile?.absolutePath
?: throw InitializerException.DatabasePathException
val prefix = if (alias.endsWith('_')) alias else "${alias}_"
return File(parentDir, "$prefix$dbFileName").absolutePath
}
/**
* Default implementation of the [WalletBirthdayStore] interface that loads checkpoints from the
* assets directory, in JSON format and stores the current birthday in shared preferences.
* Validate that the alias doesn't contain malicious characters by enforcing simple rules which
* permit the alias to be used as part of a file name for the preferences and databases. This
* enables multiple wallets to exist on one device, which is also helpful for sweeping funds.
*
* @param alias the alias to validate.
*
* @throws IllegalArgumentException whenever the alias is not less than 100 characters or
* contains something other than alphanumeric characters. Underscores are allowed but aliases
* must start with a letter.
*/
class DefaultBirthdayStore(
private val appContext: Context,
private val importedBirthdayHeight: Int? = null,
val alias: String = ZcashSdk.DEFAULT_ALIAS
) : WalletBirthdayStore {
internal fun validateAlias(alias: String) {
require(alias.length in 1..99 && alias[0].isLetter()
&& alias.all { it.isLetterOrDigit() || it == '_' }) {
"ERROR: Invalid alias ($alias). For security, the alias must be shorter than 100 " +
"characters and only contain letters, digits or underscores and start with a letter"
}
}
class Builder private constructor(
val viewingKeys: MutableList<String> = mutableListOf(),
var birthday: WalletBirthdayTool.WalletBirthday? = null,
var birthdayHeight: Int? = null,
var alias: String = ZcashSdk.DEFAULT_ALIAS,
var host: String = ZcashSdk.DEFAULT_LIGHTWALLETD_HOST,
var port: Int = ZcashSdk.DEFAULT_LIGHTWALLETD_PORT
) {
constructor(block: (Builder) -> Unit) : this(mutableListOf(), null, null) {
block(this)
validate()
}
constructor(
viewingKeys: MutableList<String> = mutableListOf(),
birthday: WalletBirthdayTool.WalletBirthday? = null,
/* optional fields with default values */
alias: String = ZcashSdk.DEFAULT_ALIAS,
host: String = ZcashSdk.DEFAULT_LIGHTWALLETD_HOST,
port: Int = ZcashSdk.DEFAULT_LIGHTWALLETD_PORT
) : this(viewingKeys, birthday, -1, alias, host, port) {
validate()
}
constructor(
viewingKeys: MutableList<String> = mutableListOf(),
birthdayHeight: Int = -1,
/* optional fields with default values */
alias: String = ZcashSdk.DEFAULT_ALIAS,
host: String = ZcashSdk.DEFAULT_LIGHTWALLETD_HOST,
port: Int = ZcashSdk.DEFAULT_LIGHTWALLETD_PORT
) : this(viewingKeys, null, birthdayHeight, alias, host, port) {
validate()
}
fun build(context: Context): Initializer {
if (birthday == null) {
birthday = WalletBirthdayTool.loadNearest(context, birthdayHeight)
}
return Initializer(context, this)
}
/**
* Birthday that helps new wallets not have to scan from the beginning, which saves
* significant amounts of startup time. This value is created using the context passed into
* the constructor.
* Add viewing keys to the set of accounts to monitor. Note: Using more than one viewing key
* is not currently well supported. Consider it an alpha-preview feature that might work but
* probably has serious bugs.
*/
override val newWalletBirthday: WalletBirthday get() = WalletBirthdayTool.loadNearest(appContext)
fun setViewingKeys(vararg extendedFullViewingKeys: String) {
viewingKeys.apply {
clear()
addAll(extendedFullViewingKeys)
}
}
/**
* Birthday to use whenever no birthday is known, meaning we have to scan from the first
* time a transaction could have happened. This is the most efficient value we can use in
* this least efficient circumstance. This value is created using the context passed into
* the constructor and it is a different value for mainnet and testnet.
* Add viewing key to the set of accounts to monitor. Note: Using more than one viewing key
* is not currently well supported. Consider it an alpha-preview feature that might work but
* probably has serious bugs.
*/
private val saplingBirthday: WalletBirthday get() =
WalletBirthdayTool.loadExact(appContext, ZcashSdk.SAPLING_ACTIVATION_HEIGHT)
fun addViewingKey(extendedFullViewingKey: String) {
viewingKeys.add(extendedFullViewingKey)
}
/**
* Preferences where the birthday is stored.
* Load the most recent checkpoint available. This is useful for new wallets.
*/
private val prefs: SharedPreferences = SharedPrefs(appContext, alias)
fun newWalletBirthday() {
birthdayHeight = null
}
init {
/**
* Load the birthday checkpoint closest to the given wallet birthday. This is useful when
* importing a pre-existing wallet. It is the same as calling
* `birthdayHeight = importedHeight`.
*/
fun importedWalletBirthday(importedHeight: Int) {
birthdayHeight = importedHeight
}
/**
* Theoretically, the oldest possible birthday a wallet could have. Useful for searching
* all transactions on the chain. In reality, no wallets were born at this height.
*/
fun saplingBirthday() {
birthdayHeight = ZcashSdk.SAPLING_ACTIVATION_HEIGHT
}
//
// Convenience functions
//
fun server(host: String, port: Int) {
this.host = host
this.port = port
}
fun import(seed: ByteArray, birthdayHeight: Int) {
setSeed(seed)
importedWalletBirthday(birthdayHeight)
}
fun import(
viewingKey: String,
birthdayHeight: Int,
host: String = ZcashSdk.DEFAULT_LIGHTWALLETD_HOST,
port: Int = ZcashSdk.DEFAULT_LIGHTWALLETD_PORT
) {
setViewingKeys(viewingKey)
server(host, port)
this.birthdayHeight = birthdayHeight
}
fun new(
seed: ByteArray,
host: String = ZcashSdk.DEFAULT_LIGHTWALLETD_HOST,
port: Int = ZcashSdk.DEFAULT_LIGHTWALLETD_PORT
) {
setSeed(seed)
server(host, port)
newWalletBirthday()
}
fun new(
viewingKey: String,
host: String = ZcashSdk.DEFAULT_LIGHTWALLETD_HOST,
port: Int = ZcashSdk.DEFAULT_LIGHTWALLETD_PORT
) {
setViewingKeys(viewingKey)
server(host, port)
newWalletBirthday()
}
/**
* Convenience method for setting thew viewingKeys from a given seed. This is the same as
* calling `setViewingKeys` with the keys that match this seed.
*/
fun setSeed(seed: ByteArray, numberOfAccounts: Int = 1) {
setViewingKeys(*DerivationTool.deriveViewingKeys(seed, numberOfAccounts))
}
//
// Validation helpers
//
fun validate() {
validateAlias(alias)
validateViewingKeys()
validateBirthday()
}
override fun hasExistingBirthday(): Boolean = loadBirthdayFromPrefs(prefs) != null
private fun validateBirthday() {
// one of the fields must be properly set
require((birthdayHeight ?: -1) >= ZcashSdk.SAPLING_ACTIVATION_HEIGHT
|| birthdayHeight != null) {
"Birthday is required but was not set on this initializer. Verify that a valid" +
" birthday was provided when creating the Initializer such as" +
" WalletBirthdayTool.loadNearest()"
}
override fun hasImportedBirthday(): Boolean = importedBirthdayHeight != null
// but not both
require((birthdayHeight ?: -1) < ZcashSdk.SAPLING_ACTIVATION_HEIGHT
|| birthday == null) {
"Ambiguous birthday. Either the birthday Object or the birthdayHeight Int should" +
" be set but not both."
}
override fun getBirthday(): WalletBirthday {
return loadBirthdayFromPrefs(prefs).apply { twig("Loaded birthday from prefs: ${this?.height}") } ?: saplingBirthday.apply { twig("returning sapling birthday") }
}
override fun setBirthday(value: WalletBirthday) {
twig("Setting birthday to ${value.height}")
saveBirthdayToPrefs(prefs, value)
}
override fun loadBirthday(birthdayHeight: Int) =
WalletBirthdayTool.loadNearest(appContext, birthdayHeight)
/**
* Retrieves the birthday-related primitives from the given preference object and then uses
* them to construct and return a birthday instance. It assumes that if the first preference
* is there, the rest will be too. If that's not the case, a call to this function will
* result in an exception.
*
* @param prefs the shared preference to use for loading the birthday.
*
* @return a birthday from preferences if one exists and null, otherwise null
*/
private fun loadBirthdayFromPrefs(prefs: SharedPreferences?): WalletBirthday? {
prefs ?: return null
val height: Int? = prefs[PREFS_BIRTHDAY_HEIGHT]
return height?.let {
runCatching {
WalletBirthday(
it,
prefs[PREFS_BIRTHDAY_HASH]!!,
prefs[PREFS_BIRTHDAY_TIME]!!,
prefs[PREFS_BIRTHDAY_TREE]!!
)
}.getOrNull()
// the field that is set should contain a proper value
require(
(birthdayHeight ?: birthday?.height ?: -1) >= ZcashSdk.SAPLING_ACTIVATION_HEIGHT
) {
"Invalid birthday height of ${birthdayHeight ?: birthday?.height}. The birthday" +
" height must be at least the height of Sapling activation on" +
" ${ZcashSdk.NETWORK} (${ZcashSdk.SAPLING_ACTIVATION_HEIGHT})."
}
}
/**
* Save the given birthday to the given preferences.
*
* @param prefs the shared preferences to use for saving the birthday.
* @param birthday the birthday to save. It will be split into primitives.
*/
private fun saveBirthdayToPrefs(prefs: SharedPreferences, birthday: WalletBirthday) {
twig("saving birthday to prefs (${birthday.height})")
prefs[PREFS_BIRTHDAY_HEIGHT] = birthday.height
prefs[PREFS_BIRTHDAY_HASH] = birthday.hash
prefs[PREFS_BIRTHDAY_TIME] = birthday.time
prefs[PREFS_BIRTHDAY_TREE] = birthday.tree
}
/**
* Static helper functions that facilitate initializing the birthday.
*/
companion object {
//
// Preference Keys
//
private const val PREFS_HAS_DATA = "Initializer.prefs.hasData"
private const val PREFS_BIRTHDAY_HEIGHT = "Initializer.prefs.birthday.height"
private const val PREFS_BIRTHDAY_TIME = "Initializer.prefs.birthday.time"
private const val PREFS_BIRTHDAY_HASH = "Initializer.prefs.birthday.hash"
private const val PREFS_BIRTHDAY_TREE = "Initializer.prefs.birthday.tree"
/**
* Directory within the assets folder where birthday data
* (i.e. sapling trees for a given height) can be found.
*/
private const val BIRTHDAY_DIRECTORY = "zcash/saplingtree"
/**
* A convenience constructor function for creating an instance of this class to use for
* new wallets. It sets the stored birthday to match the `newWalletBirthday` checkpoint
* which is typically the most recent checkpoint available.
*
* @param appContext the application context.
* @param alias the alias to use when naming the preferences file used for storage.
*/
fun NewWalletBirthdayStore(appContext: Context, alias: String = ZcashSdk.DEFAULT_ALIAS): WalletBirthdayStore {
return DefaultBirthdayStore(appContext, alias = alias).apply {
setBirthday(newWalletBirthday)
}
private fun validateViewingKeys() {
require(viewingKeys.isNotEmpty()) {
"Viewing keys are required. Ensure that the viewing keys or seed have been set" +
" on this Initializer."
}
/**
* A convenience constructor function for creating an instance of this class to use for
* imported wallets. It sets the stored birthday to match the given
* `importedBirthdayHeight` by finding the highest checkpoint that is below that height.
*
* @param appContext the application context.
* @param importedBirthdayHeight the height corresponding to the birthday of the wallet
* being imported. A checkpoint will be generated that allows scanning to start as close
* to this height as possible because any blocks before this height can safely be
* ignored since a wallet cannot have transactions before it is born.
* @param alias the alias to use when naming the preferences file used for storage.
*/
fun ImportedWalletBirthdayStore(appContext: Context, importedBirthdayHeight: Int?, alias: String = ZcashSdk.DEFAULT_ALIAS): WalletBirthdayStore {
return DefaultBirthdayStore(appContext, alias = alias).apply {
if (importedBirthdayHeight != null) {
saveBirthdayToPrefs(prefs, WalletBirthdayTool.loadNearest(appContext, importedBirthdayHeight))
} else {
setBirthday(newWalletBirthday)
}
}
}
/*
* Helper functions for using SharedPreferences
*/
/**
* Convenient constructor function for SharedPreferences used by this class.
*/
@Suppress("FunctionName")
private fun SharedPrefs(context: Context, name: String = "prefs"): SharedPreferences {
val fileName = "${BuildConfig.FLAVOR}.${BuildConfig.BUILD_TYPE}.$name".toLowerCase()
return context.getSharedPreferences(fileName, Context.MODE_PRIVATE)!!
}
private inline fun SharedPreferences.edit(block: (SharedPreferences.Editor) -> Unit) {
edit().run {
block(this)
apply()
}
}
private operator fun SharedPreferences.set(key: String, value: Any?) {
when (value) {
is String? -> edit { it.putString(key, value) }
is Int -> edit { it.putInt(key, value) }
is Boolean -> edit { it.putBoolean(key, value) }
is Float -> edit { it.putFloat(key, value) }
is Long -> edit { it.putLong(key, value) }
else -> throw UnsupportedOperationException("Not yet implemented")
}
}
private inline operator fun <reified T : Any> SharedPreferences.get(
key: String,
defaultValue: T? = null
): T? {
return when (T::class) {
String::class -> getString(key, defaultValue as? String) as T?
Int::class -> getInt(key, defaultValue as? Int ?: -1) as T?
Boolean::class -> getBoolean(key, defaultValue as? Boolean ?: false) as T?
Float::class -> getFloat(key, defaultValue as? Float ?: -1f) as T?
Long::class -> getLong(key, defaultValue as? Long ?: -1) as T?
else -> throw UnsupportedOperationException("Not yet implemented")
}
viewingKeys.forEach {
DerivationTool.validateViewingKey(it)
}
}
}
}
/**
* Convenience extension for importing from an integer height, rather than a wallet birthday object.
*
* @param alias the prefix to use for the cache of blocks downloaded and the data decrypted from
* those blocks. Using different names helps for use cases that involve multiple keys.
* @param overwrite when true, this will delete all existing data. Use with caution because this can
* result in a loss of funds, when a user has not backed up their seed. This parameter is most
* useful during testing or proof of concept work, where you want to run the same code each time so
* data must be cleared between runs.
*
* @return the spending keys, derived from the seed, for convenience.
*/
fun Initializer.import(
seed: ByteArray,
birthdayHeight: Int,
alias: String = ZcashSdk.DEFAULT_ALIAS,
overwrite: Boolean = false
): Array<String> {
return ImportedWalletBirthdayStore(context, birthdayHeight, alias).getBirthday().let {
import(seed, it, clearCacheDb = overwrite, clearDataDb = overwrite)
}
}
/**
* Validate that the alias doesn't contain malicious characters by enforcing simple rules which
* permit the alias to be used as part of a file name for the preferences and databases. This
@ -605,4 +361,3 @@ internal fun validateAlias(alias: String) {
"characters and only contain letters, digits or underscores and start with a letter"
}
}

View File

@ -78,7 +78,7 @@ class SdkSynchronizer internal constructor(
* the underlying channel to connect to the same service, and use other APIs
* (such as darksidewalletd) because channels are heavyweight.
*/
val channel: ManagedChannel get() = (processor.downloader.lightwalletService as LightWalletGrpcService).channel
val channel: ManagedChannel get() = (processor.downloader.lightWalletService as LightWalletGrpcService).channel
var isStarted = false
@ -223,7 +223,21 @@ class SdkSynchronizer internal constructor(
*/
override suspend fun getServerInfo(): Service.LightdInfo = processor.downloader.getServerInfo()
/**
* Changes the server that is being used to download compact blocks. This will throw an
* exception if it detects that the server change is invalid e.g. switching to testnet from
* mainnet.
*/
override suspend fun changeServer(host: String, port: Int, errorHandler: (Throwable) -> Unit) {
val info =
(processor.downloader.lightWalletService as LightWalletGrpcService).connectionInfo
processor.downloader.changeService(
LightWalletGrpcService(info.appContext, host, port),
errorHandler
)
}
//
// Storage APIs
//
@ -294,6 +308,15 @@ class SdkSynchronizer internal constructor(
if (error.cause?.cause != null) twig("******** caused by ${error.cause?.cause}")
twig("********")
if (onCriticalErrorHandler == null) {
twig(
"WARNING: a critical error occurred but no callback is registered to be notified " +
"of critical errors! THIS IS PROBABLY A MISTAKE. To respond to these " +
"errors (perhaps to update the UI or alert the user) set " +
"synchronizer.onCriticalErrorHandler to a non-null value."
)
}
onCriticalErrorHandler?.invoke(error)
}

View File

@ -5,6 +5,7 @@ import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.block.CompactBlockProcessor.WalletBalance
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.validate.AddressType
import cash.z.ecc.android.sdk.validate.ConsensusMatchType
import cash.z.wallet.sdk.rpc.Service
@ -221,6 +222,18 @@ interface Synchronizer {
*/
suspend fun getServerInfo(): Service.LightdInfo
/**
* Gracefully change the server that the Synchronizer is currently using. In some cases, this
* will require waiting until current network activity is complete. Ideally, this would protect
* against accidentally switching between testnet and mainnet, by comparing the service info of
* the existing server with that of the new one.
*/
suspend fun changeServer(
host: String,
port: Int = ZcashSdk.DEFAULT_LIGHTWALLETD_PORT,
errorHandler: (Throwable) -> Unit = { throw it }
)
//
// Error Handling

View File

@ -1,267 +0,0 @@
package cash.z.ecc.android.sdk
import android.content.Context
import cash.z.ecc.android.sdk.exception.InitializerException
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.tryWarn
import cash.z.ecc.android.sdk.ext.twig
import cash.z.ecc.android.sdk.jni.RustBackend
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.tool.WalletBirthdayTool
import java.io.File
import java.lang.IllegalArgumentException
/**
* Simplified Initializer focused on starting from a ViewingKey.
*/
class VkInitializer(appContext: Context, block: Builder.() -> Unit) : SdkSynchronizer.SdkInitializer {
override val context = appContext.applicationContext
override val rustBackend: RustBackend
override val alias: String
override val host: String
override val port: Int
val viewingKeys: Array<out String>
val birthday: WalletBirthdayTool.WalletBirthday
init {
Builder(block).let { builder ->
birthday = builder._birthday
viewingKeys = builder._viewingKeys
alias = builder.alias
host = builder.host
port = builder.port
rustBackend = initRustBackend(birthday)
initMissingDatabases(birthday, *viewingKeys)
}
}
private fun initRustBackend(birthday: WalletBirthdayTool.WalletBirthday): RustBackend {
return RustBackend().init(
cacheDbPath(context, alias),
dataDbPath(context, alias),
"${context.cacheDir.absolutePath}/params",
birthday.height
)
}
private fun initMissingDatabases(
birthday: WalletBirthdayTool.WalletBirthday,
vararg viewingKeys: String
) {
maybeCreateDataDb()
maybeInitBlocksTable(birthday)
maybeInitAccountsTable(*viewingKeys)
}
/**
* Create the dataDb and its table, if it doesn't exist.
*/
private fun maybeCreateDataDb() {
tryWarn("Warning: did not create dataDb. It probably already exists.") {
rustBackend.initDataDb()
twig("Initialized wallet for first run")
}
}
/**
* Initialize the blocks table with the given birthday, if needed.
*/
private fun maybeInitBlocksTable(birthday: WalletBirthdayTool.WalletBirthday) {
tryWarn(
"Warning: did not initialize the blocks table. It probably was already initialized."
) {
rustBackend.initBlocksTable(
birthday.height,
birthday.hash,
birthday.time,
birthday.tree
)
twig("seeded the database with sapling tree at height ${birthday.height}")
}
}
/**
* Initialize the accounts table with the given viewing keys, if needed.
*/
private fun maybeInitAccountsTable(vararg viewingKeys: String) {
tryWarn(
"Warning: did not initialize the accounts table. It probably was already initialized."
) {
rustBackend.initAccountsTable(*viewingKeys)
twig("Initialized the accounts table with ${viewingKeys.size} viewingKey(s)")
}
}
/**
* Delete all local data related to this wallet, as though the wallet was never created on this
* device. Simply put, this call deletes the "cache db" and "data db."
*/
override fun clear() {
rustBackend.clear()
}
//
// Path Helpers
//
/**
* Returns the path to the cache database that would correspond to the given alias.
*
* @param appContext the application context
* @param alias the alias to convert into a database path
*/
fun cacheDbPath(appContext: Context, alias: String): String =
aliasToPath(appContext, alias, ZcashSdk.DB_CACHE_NAME)
/**
* Returns the path to the data database that would correspond to the given alias.
* @param appContext the application context
* @param alias the alias to convert into a database path
*/
fun dataDbPath(appContext: Context, alias: String): String =
aliasToPath(appContext, alias, ZcashSdk.DB_DATA_NAME)
private fun aliasToPath(appContext: Context, alias: String, dbFileName: String): String {
val parentDir: String =
appContext.getDatabasePath("unused.db").parentFile?.absolutePath
?: throw InitializerException.DatabasePathException
val prefix = if (alias.endsWith('_')) alias else "${alias}_"
return File(parentDir, "$prefix$dbFileName").absolutePath
}
/**
* Validate that the alias doesn't contain malicious characters by enforcing simple rules which
* permit the alias to be used as part of a file name for the preferences and databases. This
* enables multiple wallets to exist on one device, which is also helpful for sweeping funds.
*
* @param alias the alias to validate.
*
* @throws IllegalArgumentException whenever the alias is not less than 100 characters or
* contains something other than alphanumeric characters. Underscores are allowed but aliases
* must start with a letter.
*/
internal fun validateAlias(alias: String) {
require(alias.length in 1..99 && alias[0].isLetter()
&& alias.all { it.isLetterOrDigit() || it == '_' }) {
"ERROR: Invalid alias ($alias). For security, the alias must be shorter than 100 " +
"characters and only contain letters, digits or underscores and start with a letter"
}
}
inner class Builder(block: Builder.() -> Unit) {
/* lateinit fields that can be set in multiple ways on this builder */
lateinit var _birthday: WalletBirthdayTool.WalletBirthday
private set
lateinit var _viewingKeys: Array<out String>
private set
/* optional fields with default values */
var alias: String = ZcashSdk.DEFAULT_ALIAS
var host: String = ZcashSdk.DEFAULT_LIGHTWALLETD_HOST
var port: Int = ZcashSdk.DEFAULT_LIGHTWALLETD_PORT
var birthdayHeight: Int? = null
set(value) {
field = value
_birthday = WalletBirthdayTool(context).loadNearest(value)
}
init {
block()
validateAlias(alias)
validateViewingKeys()
validateBirthday()
}
fun viewingKeys(vararg extendedFullViewingKeys: String) {
_viewingKeys = extendedFullViewingKeys
}
fun seed(seed: ByteArray, numberOfAccounts: Int = 1) {
_viewingKeys = DerivationTool.deriveViewingKeys(seed, numberOfAccounts)
}
private fun birthday(walletBirthday: WalletBirthdayTool.WalletBirthday) {
_birthday = walletBirthday
}
/**
* Load the most recent checkpoint available. This is useful for new wallets.
*/
fun newWalletBirthday() {
birthdayHeight = null
}
/**
* Load the birthday checkpoint closest to the given wallet birthday. This is useful when
* importing a pre-existing wallet. It is the same as calling
* `birthdayHeight = importedHeight`.
*/
fun importedWalletBirthday(importedHeight: Int) {
birthdayHeight = importedHeight
}
/**
* Theoretically, the oldest possible birthday a wallet could have. Useful for searching
* all transactions on the chain. In reality, no wallets were born at this height.
*/
fun saplingBirthday() {
birthdayHeight = ZcashSdk.SAPLING_ACTIVATION_HEIGHT
}
//
// Convenience functions
//
fun server(host: String, port: Int) {
this.host = host
this.port = port
}
fun import(seed: ByteArray, birthdayHeight: Int) {
seed(seed)
importedWalletBirthday(birthdayHeight)
}
fun new(seed: ByteArray) {
seed(seed)
newWalletBirthday()
}
//
// Validation helpers
//
private fun validateBirthday() {
require(::_birthday.isInitialized) {
"Birthday is required but was not set on this initializer. Verify that a valid" +
" birthday was provided when creating the Initializer such as" +
" WalletBirthdayTool.loadNearest()"
}
require(_birthday.height >= ZcashSdk.SAPLING_ACTIVATION_HEIGHT) {
"Invalid birthday height of ${_birthday.height}. The birthday height must be at" +
" least the height of Sapling activation on ${ZcashSdk.NETWORK}" +
" (${ZcashSdk.SAPLING_ACTIVATION_HEIGHT})."
}
}
private fun validateViewingKeys() {
require(::_viewingKeys.isInitialized && _viewingKeys.isNotEmpty()) {
"Viewing keys are required. Ensure that the viewing keys or seed have been set" +
" on this Initializer."
}
_viewingKeys.forEach {
DerivationTool.validateViewingKey(it)
}
}
}
}

View File

@ -1,8 +1,14 @@
package cash.z.ecc.android.sdk.block
import cash.z.ecc.android.sdk.exception.LightWalletException
import cash.z.ecc.android.sdk.ext.tryWarn
import cash.z.ecc.android.sdk.service.LightWalletService
import cash.z.wallet.sdk.rpc.Service
import io.grpc.StatusRuntimeException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
@ -11,13 +17,20 @@ import kotlinx.coroutines.withContext
* these dependencies, the downloader remains agnostic to the particular implementation of how to retrieve and store
* data; although, by default the SDK uses gRPC and SQL.
*
* @property lightwalletService the service used for requesting compact blocks
* @property lightWalletService the service used for requesting compact blocks
* @property compactBlockStore responsible for persisting the compact blocks that are received
*/
open class CompactBlockDownloader(
val lightwalletService: LightWalletService,
val compactBlockStore: CompactBlockStore
) {
open class CompactBlockDownloader private constructor(val compactBlockStore: CompactBlockStore) {
lateinit var lightWalletService: LightWalletService
private set
constructor(
lightWalletService: LightWalletService,
compactBlockStore: CompactBlockStore
) : this(compactBlockStore) {
this.lightWalletService = lightWalletService
}
/**
* Requests the given range of blocks from the lightwalletService and then persists them to the
@ -29,7 +42,7 @@ open class CompactBlockDownloader(
* @return the number of blocks that were returned in the results from the lightwalletService.
*/
suspend fun downloadBlockRange(heightRange: IntRange): Int = withContext(IO) {
val result = lightwalletService.getBlockRange(heightRange)
val result = lightWalletService.getBlockRange(heightRange)
compactBlockStore.write(result)
result.size
}
@ -50,7 +63,7 @@ open class CompactBlockDownloader(
* @return the latest block height.
*/
suspend fun getLatestBlockHeight() = withContext(IO) {
lightwalletService.getLatestBlockHeight()
lightWalletService.getLatestBlockHeight()
}
/**
@ -63,14 +76,42 @@ open class CompactBlockDownloader(
}
suspend fun getServerInfo(): Service.LightdInfo = withContext(IO) {
lightwalletService.getServerInfo()
lightWalletService.getServerInfo()
}
suspend fun changeService(
newService: LightWalletService,
errorHandler: (Throwable) -> Unit = { throw it }
) = withContext(IO) {
try {
val existing = lightWalletService.getServerInfo()
val new = newService.getServerInfo()
val nonMatching = existing.essentialPropertyDiff(new)
if (nonMatching.size > 0) {
errorHandler(
LightWalletException.ChangeServerException.ChainInfoNotMatching(
nonMatching.joinToString(),
existing,
new
)
)
}
gracefullyShutdown(lightWalletService)
lightWalletService = newService
} catch (s: StatusRuntimeException) {
errorHandler(LightWalletException.ChangeServerException.StatusException(s.status))
} catch (t: Throwable) {
errorHandler(t)
}
}
/**
* Stop this downloader and cleanup any resources being used.
*/
fun stop() {
lightwalletService.shutdown()
lightWalletService.shutdown()
compactBlockStore.close()
}
@ -79,7 +120,35 @@ open class CompactBlockDownloader(
*
* @return the full transaction info.
*/
fun fetchTransaction(txId: ByteArray) = lightwalletService.fetchTransaction(txId)
fun fetchTransaction(txId: ByteArray) = lightWalletService.fetchTransaction(txId)
//
// Convenience functions
//
private suspend fun CoroutineScope.gracefullyShutdown(service: LightWalletService) = launch {
delay(2_000L)
tryWarn("Warning: error while shutting down service") {
service.shutdown()
}
}
/**
* Return a list of critical properties that do not match.
*/
private fun Service.LightdInfo.essentialPropertyDiff(other: Service.LightdInfo) =
mutableListOf<String>().also {
if (!consensusBranchId.equals(other.consensusBranchId, true)) {
it.add("consensusBranchId")
}
if (saplingActivationHeight != other.saplingActivationHeight) {
it.add("saplingActivationHeight")
}
if (!chainName.equals(other.chainName, true)) {
it.add("chainName")
}
}
}

View File

@ -170,7 +170,7 @@ class CompactBlockProcessor(
if (!updateRanges()) {
twig("Disconnection detected! Attempting to reconnect!")
setState(Disconnected)
downloader.lightwalletService.reconnect()
downloader.lightWalletService.reconnect()
ERROR_CODE_RECONNECT
} else if (currentInfo.lastDownloadRange.isEmpty() && currentInfo.lastScanRange.isEmpty()) {
twig("Nothing to process: no new blocks to download or scan, right now.")

View File

@ -280,9 +280,10 @@ interface TransactionDao {
ON transactions.id_tx = sent_notes.tx
LEFT JOIN blocks
ON transactions.block = blocks.height
WHERE ( transactions.raw IS NULL
AND received_notes.is_change != 1 )
OR ( transactions.raw IS NOT NULL )
/* we want all received txs except those that are change and all sent transactions (even those that haven't been mined yet). Note: every entry in the 'send_notes' table has a non-null value for 'address' */
WHERE ( sent_notes.address IS NULL
AND received_notes.is_change != 1 )
OR sent_notes.address IS NOT NULL
ORDER BY ( minedheight IS NOT NULL ),
minedheight DESC,
blocktimeinseconds DESC,

View File

@ -1,5 +1,9 @@
package cash.z.ecc.android.sdk.exception
import cash.z.wallet.sdk.rpc.Service
import io.grpc.Status
import io.grpc.Status.Code.UNAVAILABLE
/**
* Marker for all custom exceptions from the SDK. Making it an interface would result in more typing
@ -76,11 +80,6 @@ sealed class BirthdayException(message: String, cause: Throwable? = null) : SdkE
class MissingBirthdayFilesException(directory: String) : BirthdayException(
"Cannot initialize wallet because no birthday files were found in the $directory directory."
)
class MissingBirthdayException(val alias: String) : BirthdayException(
"Failed to initialize wallet with alias=$alias because its birthday could not be found." +
" Verify the alias or perhaps a new wallet should be created, instead."
)
class ExactBirthdayNotFoundException(height: Int, nearestMatch: Int? = null): BirthdayException(
"Unable to find birthday that exactly matches $height.${
if (nearestMatch != null)
@ -104,6 +103,18 @@ sealed class InitializerException(message: String, cause: Throwable? = null) :
class FalseStart(cause: Throwable?) : InitializerException("Failed to initialize accounts due to: $cause", cause)
class AlreadyInitializedException(cause: Throwable, dbPath: String) : InitializerException("Failed to initialize the blocks table" +
" because it already exists in $dbPath", cause)
object MissingBirthdayException : InitializerException(
"Expected a birthday for this wallet but failed to find one. This usually means that " +
"wallet setup did not happen correctly. A workaround might be to interpret the " +
"birthday, based on the contents of the wallet data but it is probably better " +
"not to mask this error because the root issue should be addressed."
)
object MissingViewingKeyException : InitializerException(
"Expected a viewingKey for this wallet but failed to find one. This usually means that " +
"wallet setup happened incorrectly. A workaround might be to derive the " +
"viewingKey from the seed or seedPhrase, if they exist, but it is probably " +
"better not to mask this error because the root issue should be addressed."
)
object DatabasePathException :
InitializerException("Critical failure to locate path for storing databases. Perhaps this" +
" device prevents apps from storing data? We cannot initialize the wallet unless" +
@ -113,21 +124,40 @@ sealed class InitializerException(message: String, cause: Throwable? = null) :
/**
* Exceptions thrown while interacting with lightwalletd.
*/
sealed class LightwalletException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object InsecureConnection : LightwalletException("Error: attempted to connect to lightwalletd" +
sealed class LightWalletException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object InsecureConnection : LightWalletException("Error: attempted to connect to lightwalletd" +
" with an insecure connection! Plaintext connections are only allowed when the" +
" resource value for 'R.bool.lightwalletd_allow_very_insecure_connections' is true" +
" because this choice should be explicit.")
class ConsensusBranchException(sdkBranch: String, lwdBranch: String) :
LightwalletException(
LightWalletException(
"Error: the lightwalletd server is using a consensus branch" +
" (branch: $lwdBranch) that does not match the transactions being created" +
" (branch: $sdkBranch). This probably means the SDK and Server are on two" +
" different chains, most likely because of a recent network upgrade (NU). Either" +
" update the SDK to match lightwalletd or use a lightwalletd that matches the SDK."
)
open class ChangeServerException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
class ChainInfoNotMatching(val propertyNames: String, val expectedInfo: Service.LightdInfo, val actualInfo: Service.LightdInfo) : ChangeServerException(
"Server change error: the $propertyNames values did not match."
)
class StatusException(val status: Status, cause: Throwable? = null) : SdkException(status.toMessage(), cause) {
companion object {
private fun Status.toMessage(): String {
return when(this.code) {
UNAVAILABLE -> {
"Error: the new server is unavailable. Verify that the host and port are correct. Failed with $this"
}
else -> "Changing servers failed with status $this"
}
}
}
}
}
}
/**
* Potentially user-facing exceptions thrown while encoding transactions.
*/

View File

@ -12,7 +12,7 @@ import java.io.File
* 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.
*/
class RustBackend : RustBackendWelding {
class RustBackend private constructor() : RustBackendWelding {
init {
load()
@ -27,27 +27,6 @@ class RustBackend : RustBackendWelding {
get() = if (field != -1) field else throw BirthdayException.UninitializedBirthdayException
private set
/**
* Loads the library and initializes path variables. Although it is best to only call this
* function once, it is idempotent.
*/
fun init(
cacheDbPath: String,
dataDbPath: String,
paramsPath: String,
birthdayHeight: Int? = null
): RustBackend {
twig("Creating RustBackend") {
pathCacheDb = cacheDbPath
pathDataDb = dataDbPath
pathParamsDir = paramsPath
if (birthdayHeight != null) {
this.birthdayHeight = birthdayHeight
}
}
return this
}
fun clear(clearCacheDb: Boolean = true, clearDataDb: Boolean = true) {
if (clearCacheDb) {
twig("Deleting the cache database!")
@ -158,6 +137,26 @@ class RustBackend : RustBackendWelding {
companion object {
private var loaded = false
/**
* Loads the library and initializes path variables. Although it is best to only call this
* function once, it is idempotent.
*/
fun init(
cacheDbPath: String,
dataDbPath: String,
paramsPath: String,
birthdayHeight: Int? = null
): RustBackend {
return RustBackend().apply {
pathCacheDb = cacheDbPath
pathDataDb = dataDbPath
pathParamsDir = paramsPath
if (birthdayHeight != null) {
this.birthdayHeight = birthdayHeight
}
}
}
fun load() {
// 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.

View File

@ -2,7 +2,8 @@ package cash.z.ecc.android.sdk.service
import android.content.Context
import cash.z.ecc.android.sdk.R
import cash.z.ecc.android.sdk.exception.LightwalletException
import cash.z.ecc.android.sdk.annotation.OpenForTesting
import cash.z.ecc.android.sdk.exception.LightWalletException
import cash.z.ecc.android.sdk.ext.ZcashSdk.DEFAULT_LIGHTWALLETD_PORT
import cash.z.ecc.android.sdk.ext.twig
import cash.z.wallet.sdk.rpc.CompactFormats
@ -18,18 +19,19 @@ import java.util.concurrent.TimeUnit
* Implementation of LightwalletService using gRPC for requests to lightwalletd.
*
* @property channel the channel to use for communicating with the lightwalletd server.
* @property singleRequestTimeoutSec the timeout to use for non-streaming requests. When a new stub is
* created, it will use a deadline that is after the given duration from now.
* @property streamingRequestTimeoutSec the timeout to use for streaming requests. When a new stub is
* created for streaming requests, it will use a deadline that is after the given duration from now.
* @property singleRequestTimeoutSec the timeout to use for non-streaming requests. When a new stub
* is created, it will use a deadline that is after the given duration from now.
* @property streamingRequestTimeoutSec the timeout to use for streaming requests. When a new stub
* is created for streaming requests, it will use a deadline that is after the given duration from
* now.
*/
@OpenForTesting
class LightWalletGrpcService private constructor(
var channel: ManagedChannel,
private val singleRequestTimeoutSec: Long = 10L,
private val streamingRequestTimeoutSec: Long = 90L
) : LightWalletService {
//TODO: find a better way to do this, maybe change the constructor to keep the properties
lateinit var connectionInfo: ConnectionInfo
/**
@ -46,7 +48,8 @@ class LightWalletGrpcService private constructor(
appContext: Context,
host: String,
port: Int = DEFAULT_LIGHTWALLETD_PORT,
usePlaintext: Boolean = appContext.resources.getBoolean(R.bool.lightwalletd_allow_very_insecure_connections)
usePlaintext: Boolean =
appContext.resources.getBoolean(R.bool.lightwalletd_allow_very_insecure_connections)
) : this(createDefaultChannel(appContext, host, port, usePlaintext)) {
connectionInfo = ConnectionInfo(appContext.applicationContext, host, port, usePlaintext)
}
@ -57,17 +60,20 @@ class LightWalletGrpcService private constructor(
if (heightRange.isEmpty()) return listOf()
channel.resetConnectBackoff()
return channel.createStub(streamingRequestTimeoutSec).getBlockRange(heightRange.toBlockRange()).toList()
return channel.createStub(streamingRequestTimeoutSec)
.getBlockRange(heightRange.toBlockRange()).toList()
}
override fun getLatestBlockHeight(): Int {
channel.resetConnectBackoff()
return channel.createStub(singleRequestTimeoutSec).getLatestBlock(Service.ChainSpec.newBuilder().build()).height.toInt()
return channel.createStub(singleRequestTimeoutSec)
.getLatestBlock(Service.ChainSpec.newBuilder().build()).height.toInt()
}
override fun getServerInfo(): Service.LightdInfo {
channel.resetConnectBackoff()
return channel.createStub(singleRequestTimeoutSec).getLightdInfo(Service.Empty.newBuilder().build())
return channel.createStub(singleRequestTimeoutSec)
.getLightdInfo(Service.Empty.newBuilder().build())
}
override fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse {
@ -87,6 +93,7 @@ class LightWalletGrpcService private constructor(
}
override fun shutdown() {
twig("Shutting down channel")
channel.shutdown()
}
@ -94,7 +101,9 @@ class LightWalletGrpcService private constructor(
if (txId.isEmpty()) return null
channel.resetConnectBackoff()
return channel.createStub().getTransaction(Service.TxFilter.newBuilder().setHash(ByteString.copyFrom(txId)).build())
return channel.createStub().getTransaction(
Service.TxFilter.newBuilder().setHash(ByteString.copyFrom(txId)).build()
)
}
override fun getTAddressTransactions(
@ -112,8 +121,9 @@ class LightWalletGrpcService private constructor(
}
override fun reconnect() {
twig("closing existing channel and then reconnecting to" +
" ${connectionInfo.host}:${connectionInfo.port}?usePlaintext=${connectionInfo.usePlaintext}")
twig("closing existing channel and then reconnecting to ${connectionInfo.host}:" +
"${connectionInfo.port}?usePlaintext=${connectionInfo.usePlaintext}"
)
channel.shutdown()
channel = createDefaultChannel(
connectionInfo.appContext,
@ -128,12 +138,12 @@ class LightWalletGrpcService private constructor(
// Utilities
//
private fun Channel.createStub(timeoutSec: Long = 60L): CompactTxStreamerGrpc.CompactTxStreamerBlockingStub =
CompactTxStreamerGrpc
.newBlockingStub(this)
.withDeadlineAfter(timeoutSec, TimeUnit.SECONDS)
private fun Channel.createStub(timeoutSec: Long = 60L) = CompactTxStreamerGrpc
.newBlockingStub(this)
.withDeadlineAfter(timeoutSec, TimeUnit.SECONDS)
private inline fun Int.toBlockHeight(): Service.BlockID = Service.BlockID.newBuilder().setHeight(this.toLong()).build()
private inline fun Int.toBlockHeight(): Service.BlockID =
Service.BlockID.newBuilder().setHeight(this.toLong()).build()
private inline fun IntRange.toBlockRange(): Service.BlockRange =
Service.BlockRange.newBuilder()
@ -177,7 +187,9 @@ class LightWalletGrpcService private constructor(
.context(appContext)
.apply {
if (usePlaintext) {
if (!appContext.resources.getBoolean(R.bool.lightwalletd_allow_very_insecure_connections)) throw LightwalletException.InsecureConnection
if (!appContext.resources.getBoolean(
R.bool.lightwalletd_allow_very_insecure_connections
)) throw LightWalletException.InsecureConnection
usePlaintext()
} else {
useTransportSecurity()

View File

@ -1,6 +1,5 @@
package cash.z.ecc.android.sdk.service
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
import cash.z.wallet.sdk.rpc.CompactFormats
import cash.z.wallet.sdk.rpc.Service
@ -9,6 +8,14 @@ import cash.z.wallet.sdk.rpc.Service
* calls because async concerns are handled at a higher level.
*/
interface LightWalletService {
/**
* Fetch the details of a known transaction.
*
* @return the full transaction info.
*/
fun fetchTransaction(txId: ByteArray): Service.RawTransaction?
/**
* Return the given range of blocks.
*
@ -46,13 +53,6 @@ interface LightWalletService {
*/
fun getServerInfo(): Service.LightdInfo
/**
* Submit a raw transaction.
*
* @return the response from the server.
*/
fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse
/**
* Gets all the transactions for a given t-address over the given range. In practice, this is
* effectively the same as an RPC call to a node that's running an insight server. The data is
@ -63,11 +63,10 @@ interface LightWalletService {
fun getTAddressTransactions(tAddress: String, blockHeightRange: IntRange): List<Service.RawTransaction>
/**
* Fetch the details of a known transaction.
*
* @return the full transaction info.
* Reconnect to the same or a different server. This is useful when the connection is
* unrecoverable. That might be time to switch to a mirror or just reconnect.
*/
fun fetchTransaction(txId: ByteArray): Service.RawTransaction?
fun reconnect()
/**
* Cleanup any connections when the service is shutting down and not going to be used again.
@ -75,8 +74,10 @@ interface LightWalletService {
fun shutdown()
/**
* Reconnect to the same or a different server. This is useful when the connection is
* unrecoverable. That might be time to switch to a mirror or just reconnect.
* Submit a raw transaction.
*
* @return the response from the server.
*/
fun reconnect()
fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse
}

View File

@ -13,14 +13,14 @@ use zcash_primitives::{merkle_tree::CommitmentTree, sapling::Node};
#[cfg(feature = "mainnet")]
const START_HEIGHT: u64 = 419200;
#[cfg(feature = "mainnet")]
const LIGHTWALLETD_HOST: &str = "lightwalletd.z.cash";
const LIGHTWALLETD_HOST: &str = "lightwalletd.electriccoin.co";
#[cfg(feature = "mainnet")]
const NETWORK: &str = "mainnet";
#[cfg(not(feature = "mainnet"))]
const START_HEIGHT: u64 = 280000;
#[cfg(not(feature = "mainnet"))]
const LIGHTWALLETD_HOST: &str = "lightwalletd.testnet.z.cash";
const LIGHTWALLETD_HOST: &str = "lightwalletd.testnet.electriccoin.co";
#[cfg(not(feature = "mainnet"))]
const NETWORK: &str = "testnet";

View File

@ -711,9 +711,9 @@ pub unsafe extern "C" fn Java_cash_z_ecc_android_sdk_tool_DerivationTool_deriveT
let secp = Secp256k1::new();
let pk = PublicKey::from_secret_key(&secp, &address_sk);
let mut hash160 = ripemd160::Ripemd160::new();
hash160.input(Sha256::digest(&pk.serialize()[..].to_vec()));
hash160.update(Sha256::digest(&pk.serialize()[..].to_vec()));
let address_string = hash160
.result()
.finalize()
.to_base58check(&B58_PUBKEY_ADDRESS_PREFIX, &[]);
let output = env

View File

@ -0,0 +1,7 @@
{
"network": "mainnet",
"height": 940000,
"hash": "00000000004f3d5203454e18248bc021a2afa1a30b6517f34cd9187f2b8e2489",
"time": 1597685856,
"tree": "01dd59466cdc00ee8ceb305d15c25ef32aa2d96b7e4536e071c1d14e67aee9ee0b001200000001defc97883745ef01fbca65d4e5c0f8e22b7714b704a1c36fe8a851fdbfe3a1160178cf44f19dda025a44490dd37b96b83758d04cfa3b26476e129173e7c70cb103000167b3485f4aefed9426fc301de1138fedf6949fc806c54acc2b86d897cb6a2041000000000103e2fcbb87cd5fb954d55d094943fbac487ecda1384a8fa7dbf61097a9755e54014104c9c36ff1a2e7eda524840463de4e2c02f10412a33dd754564d76f458c525000001f1c57245fff8dbc2d3efe5a0953eafdedeb06e18a3ad4f1e4042ee76623f803200011323ddf890bfd7b94fc609b0d191982cb426b8bf4d900d04709a8b9cb1a27625"
}

View File

@ -0,0 +1,7 @@
{
"network": "mainnet",
"height": 960000,
"hash": "0000000000b5b5e0ba1c01f76b8105878ea3c2f11da53cb0ec684f5d94365421",
"time": 1599193083,
"tree": "014695c74583a750216dbc0aec38282d86fc17b595bb45a74bbee8fdbf46b5313e01c2253474715b00c618e635815bd16fb8e6368fdaa9bf8f4a1aca34ead6a7eb1c12000000010cf46f452fc9101af9ca34ae364a1c2e20bc05d454068cf1407a2ee3e0c9ca6700000001091c0b4153defbfad723bf14e1ccd07c0258ea1fcd6e9e8cf759834112ec3036000001c2c980c0777874ce748ca549838324eb775cb2ac7a8d42793edbb05ac15c5b4201162d1417d8b9659ec93ac26ba1a888719a43ab1fe0b46a33c05c2aa55fecb41b00018e1d474609c9c09894638a0ab3e656aadccaf7ddf12bcc6b6ece44a4cc79e1140001f1c57245fff8dbc2d3efe5a0953eafdedeb06e18a3ad4f1e4042ee76623f803200011323ddf890bfd7b94fc609b0d191982cb426b8bf4d900d04709a8b9cb1a27625"
}

View File

@ -0,0 +1,7 @@
{
"network": "mainnet",
"height": 970000,
"hash": "0000000001dca1d101526285476ddf0eef6d238d5b01b7fae8062edfc09812c3",
"time": 1599946198,
"tree": "01d9e6147caab719ae68cb20d976c78437634e2c999ef3a09c6ba35086d443703d00120001019d135be7b1db088c68bd76703ec2b45066bb1761619745362e61dcf55f644601d0c8f296479a73722c2e2a260ab7017b9a9e6d084b651289cfe6d3c7a00ff54e01f853ab39dbfc81e2aabefd231d3374ff794028168c725ad465e61205692fef4b014f13b6e4475cbd004b4d95aa8205ed7338224e13627ecbb19afd1937dcbc0818000185b4f1ddee3199cd1f7913b223c01c4623cd9d0e1b47df4e36aaca7717b6331f011d6c8ec914cc312ef0962d52240308b22a647f4cbd2d7c2fd420ad5fbcae5619011e43cbb05b8efc885531367e5f611fe7ce7514131be892cce3adad02e151f72b01f0d7e0d589c7e5f8fff0bdd5037aeb5d5d818d413262758c9915ded705e40f70000101d26ff60e77e23fb86a52da565c22d76f81df7f25d543ec0e58a0d692d4be2700000110b2bfd32a99e0b982a41a6dbaebf783bdb9d6af795f5f20056ce7317d15ce1101f1c57245fff8dbc2d3efe5a0953eafdedeb06e18a3ad4f1e4042ee76623f803200011323ddf890bfd7b94fc609b0d191982cb426b8bf4d900d04709a8b9cb1a27625"
}

View File

@ -0,0 +1,7 @@
{
"network": "mainnet",
"height": 980000,
"hash": "00000000005de60d31b653cdf1637f1bad62af844c6c51f38557a4e8bb74e2d7",
"time": 1600700163,
"tree": "0184330bda72e9596256847a9597d7d9476aa3d69d9dad2149314751e708da206601cd8a1a61df1e80514cd2c0a7faac8b8d7ce27d6d96bb63cb7c61c1f33e7c654312014098788b75f26108d93f0429202ffffb6cf9ffffd7278383e4d8ad2af642264600011c035ed934a11c1b48e24b6be9b2d483e7747dd082f06abf75f9a092b34cd33c01be00394a99bd33304fc4343cd928857ae7c09c176452f40d9815f9ff3ba3865d013bd7218072e1588aea0568198d37e0d83ba75f155c863355974c4eb864de0103019ed27335bc5452e320ec22a30cfe61508929016157ff2a555181a9a0623e725801328208bea2c5c83487effab780cdb36b4b82e6e7290b06d98817a160f3d79d2800019ed6779a1724a107807baf4dda9481fb940f50d85db701dda43a0989c2d62535000001d1e806194dbe171d4ad1ef8c73c1a469130caced0e24b04b8acef91c42be7a56000107771e04f7d6371bfda40ef9e04419a25c6563dcd359c85bd501de28c3c7f3250110b2bfd32a99e0b982a41a6dbaebf783bdb9d6af795f5f20056ce7317d15ce1101f1c57245fff8dbc2d3efe5a0953eafdedeb06e18a3ad4f1e4042ee76623f803200011323ddf890bfd7b94fc609b0d191982cb426b8bf4d900d04709a8b9cb1a27625"
}