Merge pull request #165 from zcash/feature/merge-initializers
Sprint 37 changes
This commit is contained in:
commit
6042eefc3a
|
@ -69,5 +69,10 @@ fastlane/screenshots
|
|||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Rust / Cargo
|
||||
fraget/
|
||||
|
||||
# other
|
||||
DecompileChecker.kt
|
||||
backup-dbs/
|
||||
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -110,4 +110,8 @@ class GetBlockRangeFragment : BaseDemoFragment<FragmentGetBlockRangeBinding>() {
|
|||
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentGetBlockRangeBinding =
|
||||
FragmentGetBlockRangeBinding.inflate(layoutInflater)
|
||||
|
||||
override fun onActionButtonClicked() {
|
||||
super.onActionButtonClicked()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.4.0'
|
||||
ext.sdk_version = '1.0.0-alpha03'
|
||||
repositories {
|
||||
google ()
|
||||
jcenter()
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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 ==================================")
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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.")
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"network": "mainnet",
|
||||
"height": 940000,
|
||||
"hash": "00000000004f3d5203454e18248bc021a2afa1a30b6517f34cd9187f2b8e2489",
|
||||
"time": 1597685856,
|
||||
"tree": "01dd59466cdc00ee8ceb305d15c25ef32aa2d96b7e4536e071c1d14e67aee9ee0b001200000001defc97883745ef01fbca65d4e5c0f8e22b7714b704a1c36fe8a851fdbfe3a1160178cf44f19dda025a44490dd37b96b83758d04cfa3b26476e129173e7c70cb103000167b3485f4aefed9426fc301de1138fedf6949fc806c54acc2b86d897cb6a2041000000000103e2fcbb87cd5fb954d55d094943fbac487ecda1384a8fa7dbf61097a9755e54014104c9c36ff1a2e7eda524840463de4e2c02f10412a33dd754564d76f458c525000001f1c57245fff8dbc2d3efe5a0953eafdedeb06e18a3ad4f1e4042ee76623f803200011323ddf890bfd7b94fc609b0d191982cb426b8bf4d900d04709a8b9cb1a27625"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"network": "mainnet",
|
||||
"height": 960000,
|
||||
"hash": "0000000000b5b5e0ba1c01f76b8105878ea3c2f11da53cb0ec684f5d94365421",
|
||||
"time": 1599193083,
|
||||
"tree": "014695c74583a750216dbc0aec38282d86fc17b595bb45a74bbee8fdbf46b5313e01c2253474715b00c618e635815bd16fb8e6368fdaa9bf8f4a1aca34ead6a7eb1c12000000010cf46f452fc9101af9ca34ae364a1c2e20bc05d454068cf1407a2ee3e0c9ca6700000001091c0b4153defbfad723bf14e1ccd07c0258ea1fcd6e9e8cf759834112ec3036000001c2c980c0777874ce748ca549838324eb775cb2ac7a8d42793edbb05ac15c5b4201162d1417d8b9659ec93ac26ba1a888719a43ab1fe0b46a33c05c2aa55fecb41b00018e1d474609c9c09894638a0ab3e656aadccaf7ddf12bcc6b6ece44a4cc79e1140001f1c57245fff8dbc2d3efe5a0953eafdedeb06e18a3ad4f1e4042ee76623f803200011323ddf890bfd7b94fc609b0d191982cb426b8bf4d900d04709a8b9cb1a27625"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"network": "mainnet",
|
||||
"height": 970000,
|
||||
"hash": "0000000001dca1d101526285476ddf0eef6d238d5b01b7fae8062edfc09812c3",
|
||||
"time": 1599946198,
|
||||
"tree": "01d9e6147caab719ae68cb20d976c78437634e2c999ef3a09c6ba35086d443703d00120001019d135be7b1db088c68bd76703ec2b45066bb1761619745362e61dcf55f644601d0c8f296479a73722c2e2a260ab7017b9a9e6d084b651289cfe6d3c7a00ff54e01f853ab39dbfc81e2aabefd231d3374ff794028168c725ad465e61205692fef4b014f13b6e4475cbd004b4d95aa8205ed7338224e13627ecbb19afd1937dcbc0818000185b4f1ddee3199cd1f7913b223c01c4623cd9d0e1b47df4e36aaca7717b6331f011d6c8ec914cc312ef0962d52240308b22a647f4cbd2d7c2fd420ad5fbcae5619011e43cbb05b8efc885531367e5f611fe7ce7514131be892cce3adad02e151f72b01f0d7e0d589c7e5f8fff0bdd5037aeb5d5d818d413262758c9915ded705e40f70000101d26ff60e77e23fb86a52da565c22d76f81df7f25d543ec0e58a0d692d4be2700000110b2bfd32a99e0b982a41a6dbaebf783bdb9d6af795f5f20056ce7317d15ce1101f1c57245fff8dbc2d3efe5a0953eafdedeb06e18a3ad4f1e4042ee76623f803200011323ddf890bfd7b94fc609b0d191982cb426b8bf4d900d04709a8b9cb1a27625"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"network": "mainnet",
|
||||
"height": 980000,
|
||||
"hash": "00000000005de60d31b653cdf1637f1bad62af844c6c51f38557a4e8bb74e2d7",
|
||||
"time": 1600700163,
|
||||
"tree": "0184330bda72e9596256847a9597d7d9476aa3d69d9dad2149314751e708da206601cd8a1a61df1e80514cd2c0a7faac8b8d7ce27d6d96bb63cb7c61c1f33e7c654312014098788b75f26108d93f0429202ffffb6cf9ffffd7278383e4d8ad2af642264600011c035ed934a11c1b48e24b6be9b2d483e7747dd082f06abf75f9a092b34cd33c01be00394a99bd33304fc4343cd928857ae7c09c176452f40d9815f9ff3ba3865d013bd7218072e1588aea0568198d37e0d83ba75f155c863355974c4eb864de0103019ed27335bc5452e320ec22a30cfe61508929016157ff2a555181a9a0623e725801328208bea2c5c83487effab780cdb36b4b82e6e7290b06d98817a160f3d79d2800019ed6779a1724a107807baf4dda9481fb940f50d85db701dda43a0989c2d62535000001d1e806194dbe171d4ad1ef8c73c1a469130caced0e24b04b8acef91c42be7a56000107771e04f7d6371bfda40ef9e04419a25c6563dcd359c85bd501de28c3c7f3250110b2bfd32a99e0b982a41a6dbaebf783bdb9d6af795f5f20056ce7317d15ce1101f1c57245fff8dbc2d3efe5a0953eafdedeb06e18a3ad4f1e4042ee76623f803200011323ddf890bfd7b94fc609b0d191982cb426b8bf4d900d04709a8b9cb1a27625"
|
||||
}
|
Loading…
Reference in New Issue