Create CompactBlockProcessor and refine responsibilities of collaborators
The synchronizer now primarily collaborates with a downloader, processor and repository; each with a more focused set of responsibilities. The downloader streams blocks into a channel, the processor saves blocks from that channel and scans for transactions, the repository exposes transaction change events.
This commit is contained in:
parent
a871c5e476
commit
4d226a8c5e
|
@ -12,6 +12,7 @@
|
||||||
.cargo/
|
.cargo/
|
||||||
bin/
|
bin/
|
||||||
gen/
|
gen/
|
||||||
|
generated/
|
||||||
out/
|
out/
|
||||||
target/
|
target/
|
||||||
jniLibs/
|
jniLibs/
|
||||||
|
|
24
build.gradle
24
build.gradle
|
@ -17,6 +17,7 @@ buildscript {
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:3.4.0-alpha05'
|
classpath 'com.android.tools.build:gradle:3.4.0-alpha05'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
|
||||||
|
classpath "org.jetbrains.kotlin:kotlin-allopen:${versions.kotlin}"
|
||||||
classpath "com.github.ben-manes:gradle-versions-plugin:0.20.0"
|
classpath "com.github.ben-manes:gradle-versions-plugin:0.20.0"
|
||||||
classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1'
|
classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1'
|
||||||
classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.7"
|
classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.7"
|
||||||
|
@ -28,12 +29,13 @@ apply plugin: 'com.android.library'
|
||||||
apply plugin: "kotlin-android-extensions"
|
apply plugin: "kotlin-android-extensions"
|
||||||
apply plugin: "kotlin-android"
|
apply plugin: "kotlin-android"
|
||||||
apply plugin: 'kotlin-kapt'
|
apply plugin: 'kotlin-kapt'
|
||||||
|
apply plugin: 'kotlin-allopen'
|
||||||
apply plugin: 'com.google.protobuf'
|
apply plugin: 'com.google.protobuf'
|
||||||
apply plugin: 'com.github.ben-manes.versions'
|
apply plugin: 'com.github.ben-manes.versions'
|
||||||
apply plugin: 'com.github.dcendents.android-maven'
|
apply plugin: 'com.github.dcendents.android-maven'
|
||||||
|
|
||||||
group = 'cash.z.android.wallet'
|
group = 'cash.z.android.wallet'
|
||||||
version = '1.2.4'
|
version = '1.4.0'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
|
@ -46,10 +48,11 @@ android {
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion 16
|
minSdkVersion 16
|
||||||
targetSdkVersion 28
|
targetSdkVersion 28
|
||||||
versionCode = 1_03_00
|
versionCode = 1_04_00
|
||||||
versionName = "1.3.0"
|
versionName = version
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
multiDexEnabled false
|
multiDexEnabled false
|
||||||
|
archivesBaseName = "zcash-android-wallet-sdk-$versionName"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
@ -90,11 +93,15 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allOpen {
|
||||||
|
// marker for classes that we want to be able to extend in debug builds for testing purposes
|
||||||
|
annotation 'cash.z.wallet.sdk.annotation.OpenClass'
|
||||||
|
}
|
||||||
|
|
||||||
clean {
|
clean {
|
||||||
delete "$project.projectDir/src/generated/source/grpc"
|
delete "$project.projectDir/src/generated/source/grpc"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
generatedFilesBaseDir = "$projectDir/src/generated/source/grpc"
|
generatedFilesBaseDir = "$projectDir/src/generated/source/grpc"
|
||||||
protoc { artifact = 'com.google.protobuf:protoc:3.6.1' }
|
protoc { artifact = 'com.google.protobuf:protoc:3.6.1' }
|
||||||
|
@ -136,10 +143,15 @@ dependencies {
|
||||||
implementation "io.grpc:grpc-stub:${versions.grpc}"
|
implementation "io.grpc:grpc-stub:${versions.grpc}"
|
||||||
implementation 'javax.annotation:javax.annotation-api:1.2'
|
implementation 'javax.annotation:javax.annotation-api:1.2'
|
||||||
|
|
||||||
|
// other
|
||||||
|
implementation "com.jakewharton.timber:timber:4.7.1"
|
||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
testImplementation 'org.mockito:mockito-junit-jupiter:2.23.0'
|
// testImplementation 'org.mockito:mockito-junit-jupiter:2.23.0'
|
||||||
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.0.0'
|
// testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0'
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.3.2'
|
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.3.2'
|
||||||
|
androidTestImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0'
|
||||||
|
androidTestImplementation 'org.mockito:mockito-android:2.23.4'
|
||||||
androidTestImplementation 'org.junit.jupiter:junit-jupiter-api:5.3.2'
|
androidTestImplementation 'org.junit.jupiter:junit-jupiter-api:5.3.2'
|
||||||
androidTestImplementation "androidx.test:runner:1.1.1"
|
androidTestImplementation "androidx.test:runner:1.1.1"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:3.1.1"
|
androidTestImplementation "androidx.test.espresso:espresso-core:3.1.1"
|
||||||
|
|
|
@ -45,7 +45,7 @@ class TransactionDaoTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDaoInsert() {
|
fun testDaoInsert() {
|
||||||
Transaction(4, "sample".toByteArray(), 356418, null).let { transaction ->
|
Transaction(4L, "sample".toByteArray(), 356418, null).let { transaction ->
|
||||||
dao.insert(transaction)
|
dao.insert(transaction)
|
||||||
val result = dao.findById(transaction.id)
|
val result = dao.findById(transaction.id)
|
||||||
assertEquals(transaction.id, result?.id)
|
assertEquals(transaction.id, result?.id)
|
||||||
|
|
|
@ -0,0 +1,155 @@
|
||||||
|
package cash.z.wallet.sdk.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import cash.z.wallet.sdk.dao.BlockDao
|
||||||
|
import cash.z.wallet.sdk.dao.NoteDao
|
||||||
|
import cash.z.wallet.sdk.dao.TransactionDao
|
||||||
|
import cash.z.wallet.sdk.jni.JniConverter
|
||||||
|
import cash.z.wallet.sdk.vo.Block
|
||||||
|
import cash.z.wallet.sdk.vo.Note
|
||||||
|
import cash.z.wallet.sdk.vo.Transaction
|
||||||
|
import com.nhaarman.mockitokotlin2.any
|
||||||
|
import com.nhaarman.mockitokotlin2.atLeast
|
||||||
|
import com.nhaarman.mockitokotlin2.mock
|
||||||
|
import com.nhaarman.mockitokotlin2.verify
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
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.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.mockito.ArgumentMatchers.anyString
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
internal class PollingTransactionRepositoryTest {
|
||||||
|
@get:Rule
|
||||||
|
var instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
|
private lateinit var repository: TransactionRepository
|
||||||
|
private lateinit var noteDao: NoteDao
|
||||||
|
private lateinit var transactionDao: TransactionDao
|
||||||
|
private lateinit var blockDao: BlockDao
|
||||||
|
private val twig = TestLogTwig()
|
||||||
|
private var ids = 0L
|
||||||
|
private var heights: Int = 123_456
|
||||||
|
private lateinit var balanceProvider: Iterator<Long>
|
||||||
|
private val pollFrequency = 100L
|
||||||
|
|
||||||
|
private lateinit var converter: JniConverter
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val dbName = "polling-test.db"
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
converter = mock {
|
||||||
|
on { getBalance(any()) }.thenAnswer { balanceProvider.next() }
|
||||||
|
}
|
||||||
|
repository = PollingTransactionRepository(context, dbName, pollFrequency, converter, twig) { db ->
|
||||||
|
blockDao = db.blockDao()
|
||||||
|
transactionDao = db.transactionDao()
|
||||||
|
noteDao = db.noteDao()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
repository.stop()
|
||||||
|
blockDao.deleteAll()
|
||||||
|
|
||||||
|
// just verify the cascading deletes are working, for sanity
|
||||||
|
assertEquals(0, blockDao.count())
|
||||||
|
assertEquals(0, transactionDao.count())
|
||||||
|
assertEquals(0, noteDao.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBalancesAreDistinct() = runBlocking<Unit> {
|
||||||
|
val balanceList = listOf(1L, 1L, 2L, 2L, 3L, 3L, 4L, 4L)
|
||||||
|
val iterations = balanceList.size
|
||||||
|
balanceProvider = balanceList.iterator()
|
||||||
|
|
||||||
|
insert(6) {
|
||||||
|
repository.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
var distinctBalances = 0
|
||||||
|
val balances = repository.balance()
|
||||||
|
twig.twigTask("waiting for balance changes") {
|
||||||
|
for (balance in balances) {
|
||||||
|
twig.twig("found balance of $balance")
|
||||||
|
distinctBalances++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(iterations, blockDao.count())
|
||||||
|
assertEquals(balanceList.distinct().size, distinctBalances)
|
||||||
|
|
||||||
|
// we at least requested the balance more times from the rust library than we got it in the channel
|
||||||
|
// (meaning the duplicates were ignored)
|
||||||
|
verify(converter, atLeast(distinctBalances + 1)).getBalance(anyString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTransactionsAreNotLost() = runBlocking<Unit> {
|
||||||
|
val iterations = 10
|
||||||
|
balanceProvider = List(iterations + 1) { it.toLong() }.iterator()
|
||||||
|
val transactionChannel = repository.transactions()
|
||||||
|
repository.start(this)
|
||||||
|
insert(iterations) {
|
||||||
|
repeat(iterations) {
|
||||||
|
assertNotNull("unexpected null for transaction number $it", transactionChannel.poll())
|
||||||
|
}
|
||||||
|
assertNull("transactions shouldn't remain", transactionChannel.poll())
|
||||||
|
assertEquals("incorrect number of items in DB", iterations, blockDao.count())
|
||||||
|
repository.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* insert [count] items, then run the code block.
|
||||||
|
*/
|
||||||
|
private fun CoroutineScope.insert(count: Int, block: suspend () -> Unit = {}) {
|
||||||
|
repeat(count) {
|
||||||
|
launch { insertItemDelayed(it * pollFrequency) }
|
||||||
|
}
|
||||||
|
launch { delay(pollFrequency * count * 2); block() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun insertItemDelayed(duration: Long) {
|
||||||
|
twig.twig("delaying $duration")
|
||||||
|
delay(duration)
|
||||||
|
|
||||||
|
val block = createBlock()
|
||||||
|
val transaction = createTransaction(block.height)
|
||||||
|
val note = createNote(transaction.id)
|
||||||
|
twig.twig("inserting note with value ${note.value}")
|
||||||
|
blockDao.insert(block)
|
||||||
|
transactionDao.insert(transaction)
|
||||||
|
noteDao.insert(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun createBlock(): Block {
|
||||||
|
return Block(heights++, System.currentTimeMillis().toInt(), byteArrayOf(heights.toByte()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTransaction(blockId: Int): Transaction {
|
||||||
|
return Transaction(ids++, byteArrayOf(ids.toByte()), blockId, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNote(id: Long): Note {
|
||||||
|
return Note(
|
||||||
|
id.toInt(),
|
||||||
|
id.toInt(),
|
||||||
|
value = Random.nextInt(0, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestLogTwig : TroubleshootingTwig(printer = { msg: String -> Log.e("TEST_LOG", msg) })
|
|
@ -1,56 +0,0 @@
|
||||||
package cash.z.wallet.sdk.data
|
|
||||||
|
|
||||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import org.junit.AfterClass
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.BeforeClass
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import cash.z.wallet.sdk.rpc.CompactFormats
|
|
||||||
|
|
||||||
class SynchronizerTest {
|
|
||||||
@get:Rule
|
|
||||||
var instantTaskExecutorRule = InstantTaskExecutorRule()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testSynchronizerExists() {
|
|
||||||
assertNotNull(synchronizer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testBlockSaving() {
|
|
||||||
// synchronizer.saveBlocks()
|
|
||||||
}
|
|
||||||
@Test
|
|
||||||
fun testBlockScanning() {
|
|
||||||
Thread.sleep(180000L)
|
|
||||||
}
|
|
||||||
private fun printFailure(result: Result<CompactFormats.CompactBlock>): String {
|
|
||||||
return if (result.isFailure) "result failed due to: ${result.exceptionOrNull()!!.let { "$it caused by: ${it.cause}" }}}"
|
|
||||||
else "success"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val job = Job()
|
|
||||||
val testScope = CoroutineScope(Dispatchers.IO + job)
|
|
||||||
val synchronizer = Synchronizer(ApplicationProvider.getApplicationContext(), testScope)
|
|
||||||
|
|
||||||
@BeforeClass
|
|
||||||
@JvmStatic
|
|
||||||
fun setup() {
|
|
||||||
synchronizer.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterClass
|
|
||||||
@JvmStatic
|
|
||||||
fun close() {
|
|
||||||
synchronizer.stop()
|
|
||||||
testScope.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -50,6 +50,13 @@ class DerivedDbIntegrationTest {
|
||||||
fun testCount_Note() {
|
fun testCount_Note() {
|
||||||
assertEquals(5, notes.count())
|
assertEquals(5, notes.count())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNoteQuery() {
|
||||||
|
val all = notes.getAll()
|
||||||
|
assertEquals(3, all.size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testTransactionDaoPrepopulated() {
|
fun testTransactionDaoPrepopulated() {
|
||||||
val tran = transactions.findById(1)
|
val tran = transactions.findById(1)
|
||||||
|
@ -75,7 +82,7 @@ class DerivedDbIntegrationTest {
|
||||||
fun setup() {
|
fun setup() {
|
||||||
// TODO: put this database in the assets directory and open it from there via .openHelperFactory(new AssetSQLiteOpenHelperFactory()) seen here https://github.com/albertogiunta/sqliteAsset
|
// TODO: put this database in the assets directory and open it from there via .openHelperFactory(new AssetSQLiteOpenHelperFactory()) seen here https://github.com/albertogiunta/sqliteAsset
|
||||||
db = Room
|
db = Room
|
||||||
.databaseBuilder(ApplicationProvider.getApplicationContext(), DerivedDataDb::class.java, "dummy-data2.db")
|
.databaseBuilder(ApplicationProvider.getApplicationContext(), DerivedDataDb::class.java, "new-data-glue2.db")
|
||||||
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
|
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.build()
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
package cash.z.wallet.sdk.db
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import cash.z.wallet.sdk.data.*
|
||||||
|
import cash.z.wallet.sdk.jni.JniConverter
|
||||||
|
import cash.z.wallet.sdk.secure.Wallet
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO:
|
||||||
|
setup a test that we can run and just watch things happen, to give confidence that logging is expressive enough to
|
||||||
|
verify that the SDK is behaving as expected.
|
||||||
|
*/
|
||||||
|
class IntegrationTest {
|
||||||
|
|
||||||
|
private val dataDbName = "IntegrationData.db"
|
||||||
|
private val cacheDdName = "IntegrationCache.db"
|
||||||
|
private val context = InstrumentationRegistry.getInstrumentation().context
|
||||||
|
|
||||||
|
private lateinit var downloader: CompactBlockStream
|
||||||
|
private lateinit var processor: CompactBlockProcessor
|
||||||
|
private lateinit var synchronizer: Synchronizer
|
||||||
|
private lateinit var repository: TransactionRepository
|
||||||
|
private lateinit var wallet: Wallet
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
deleteDbs()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteDbs() {
|
||||||
|
// prior to each run, delete the DBs for sanity
|
||||||
|
listOf(cacheDdName, dataDbName).map { context.getDatabasePath(it).absoluteFile }.forEach {
|
||||||
|
println("Deleting ${it.name}")
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSync() = runBlocking<Unit> {
|
||||||
|
val converter = JniConverter()
|
||||||
|
converter.initLogs()
|
||||||
|
val logger = TroubleshootingTwig()
|
||||||
|
|
||||||
|
downloader = CompactBlockStream("10.0.2.2", 9067, logger)
|
||||||
|
processor = CompactBlockProcessor(context, converter, cacheDdName, dataDbName, logger = logger)
|
||||||
|
repository = PollingTransactionRepository(context, dataDbName, 10_000L, converter, logger)
|
||||||
|
wallet = Wallet(converter, context.getDatabasePath(dataDbName).absolutePath, context.cacheDir.absolutePath, arrayOf(0), SampleSeedProvider("dummyseed"))
|
||||||
|
|
||||||
|
// repository.start(this)
|
||||||
|
synchronizer = Synchronizer(
|
||||||
|
downloader,
|
||||||
|
processor,
|
||||||
|
repository,
|
||||||
|
wallet,
|
||||||
|
logger
|
||||||
|
).start(this)
|
||||||
|
|
||||||
|
for(i in synchronizer.downloader.progress()) {
|
||||||
|
logger.twig("made progress: $i")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
repository.stop()
|
||||||
|
synchronizer.stop()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package cash.z.wallet.sdk.annotation
|
||||||
|
|
||||||
|
@Target(AnnotationTarget.CLASS)
|
||||||
|
annotation class OpenClass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used in conjunction with the kotlin-allopen plugin to make any class with this annotation open for extension.
|
||||||
|
* Typically, we apply this to classes that we want to mock in androidTests because unit tests don't have this problem,
|
||||||
|
* it's only an issue with JUnit4 Instrumentation tests.
|
||||||
|
*
|
||||||
|
* Note: the counterpart to this annotation in the release buildType does not apply the OpenClass annotation
|
||||||
|
*/
|
||||||
|
@OpenClass
|
||||||
|
@Target(AnnotationTarget.CLASS)
|
||||||
|
annotation class OpenForTesting
|
|
@ -2,9 +2,6 @@ package cash.z.wallet.sdk.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import cash.z.wallet.sdk.vo.Block
|
import cash.z.wallet.sdk.vo.Block
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface BlockDao {
|
interface BlockDao {
|
||||||
|
@ -23,4 +20,12 @@ interface BlockDao {
|
||||||
@Query("SELECT COUNT(height) FROM blocks")
|
@Query("SELECT COUNT(height) FROM blocks")
|
||||||
fun count(): Int
|
fun count(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM blocks")
|
||||||
|
fun deleteAll()
|
||||||
|
|
||||||
|
@Query("SELECT MAX(height) FROM blocks")
|
||||||
|
fun lastScannedHeight(): Long
|
||||||
|
|
||||||
|
@Query("UPDATE blocks SET time=:time WHERE height = :height")
|
||||||
|
fun updateTime(height: Int, time: Int)
|
||||||
}
|
}
|
|
@ -2,6 +2,7 @@ package cash.z.wallet.sdk.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import cash.z.wallet.sdk.vo.Note
|
import cash.z.wallet.sdk.vo.Note
|
||||||
|
import cash.z.wallet.sdk.vo.NoteQuery
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface NoteDao {
|
interface NoteDao {
|
||||||
|
@ -14,8 +15,23 @@ interface NoteDao {
|
||||||
@Query("DELETE FROM received_notes WHERE id_note = :id")
|
@Query("DELETE FROM received_notes WHERE id_note = :id")
|
||||||
fun deleteById(id: Int)
|
fun deleteById(id: Int)
|
||||||
|
|
||||||
@Query("SELECT * FROM received_notes WHERE 1")
|
/**
|
||||||
fun getAll(): List<Note>
|
* Query blocks, transactions and received_notes to aggregate information on send/receive
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT received_notes.tx AS txId,
|
||||||
|
received_notes.value,
|
||||||
|
transactions.block AS height,
|
||||||
|
transactions.raw IS NOT NULL AS sent,
|
||||||
|
blocks.time
|
||||||
|
FROM received_notes,
|
||||||
|
transactions,
|
||||||
|
blocks
|
||||||
|
WHERE received_notes.tx = transactions.id_tx
|
||||||
|
AND blocks.height = transactions.block
|
||||||
|
ORDER BY height DESC;
|
||||||
|
""")
|
||||||
|
fun getAll(): List<NoteQuery>
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
fun delete(block: Note)
|
fun delete(block: Note)
|
||||||
|
|
|
@ -6,19 +6,19 @@ import cash.z.wallet.sdk.vo.Transaction
|
||||||
@Dao
|
@Dao
|
||||||
interface TransactionDao {
|
interface TransactionDao {
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun insert(block: Transaction)
|
fun insert(transaction: Transaction)
|
||||||
|
|
||||||
@Query("SELECT * FROM transactions WHERE id_tx = :id")
|
@Query("SELECT * FROM transactions WHERE id_tx = :id")
|
||||||
fun findById(id: Int): Transaction?
|
fun findById(id: Long): Transaction?
|
||||||
|
|
||||||
@Query("DELETE FROM transactions WHERE id_tx = :id")
|
@Query("DELETE FROM transactions WHERE id_tx = :id")
|
||||||
fun deleteById(id: Int)
|
fun deleteById(id: Long)
|
||||||
|
|
||||||
@Query("SELECT * FROM transactions WHERE 1")
|
@Query("SELECT * FROM transactions WHERE 1")
|
||||||
fun getAll(): List<Transaction>
|
fun getAll(): List<Transaction>
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
fun delete(block: Transaction)
|
fun delete(transaction: Transaction)
|
||||||
|
|
||||||
@Query("SELECT COUNT(id_tx) FROM transactions")
|
@Query("SELECT COUNT(id_tx) FROM transactions")
|
||||||
fun count(): Int
|
fun count(): Int
|
||||||
|
|
|
@ -1,141 +0,0 @@
|
||||||
package cash.z.wallet.sdk.data
|
|
||||||
|
|
||||||
import cash.z.wallet.sdk.ext.debug
|
|
||||||
import cash.z.wallet.sdk.ext.toBlockRange
|
|
||||||
import cash.z.wallet.sdk.rpc.CompactFormats.CompactBlock
|
|
||||||
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc
|
|
||||||
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc.CompactTxStreamerBlockingStub
|
|
||||||
import cash.z.wallet.sdk.rpc.Service
|
|
||||||
import io.grpc.ManagedChannelBuilder
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
|
||||||
import kotlinx.coroutines.channels.BroadcastChannel
|
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
|
||||||
import java.io.Closeable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serves as a source of compact blocks received from the light wallet server. Once started, it will
|
|
||||||
* request all the appropriate blocks and then stream them into the channel returned when calling [start].
|
|
||||||
*/
|
|
||||||
class CompactBlockDownloader private constructor() {
|
|
||||||
private lateinit var connection: Connection
|
|
||||||
|
|
||||||
constructor(host: String, port: Int) : this() {
|
|
||||||
// TODO: improve the creation of this channel (tweak its settings to use mobile device responsibly) and make sure it is properly cleaned up
|
|
||||||
val channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build()
|
|
||||||
connection = Connection(CompactTxStreamerGrpc.newBlockingStub(channel))
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(connection: Connection) : this() {
|
|
||||||
this.connection = connection
|
|
||||||
}
|
|
||||||
|
|
||||||
fun start(
|
|
||||||
scope: CoroutineScope,
|
|
||||||
startingBlockHeight: Long = Long.MAX_VALUE,
|
|
||||||
batchSize: Int = DEFAULT_BATCH_SIZE,
|
|
||||||
pollFrequencyMillis: Long = DEFAULT_POLL_INTERVAL
|
|
||||||
): ReceiveChannel<CompactBlock> {
|
|
||||||
if(connection.isClosed()) throw IllegalStateException("Cannot start downloader when connection is closed.")
|
|
||||||
scope.launch {
|
|
||||||
delay(1000L)
|
|
||||||
connection.use {
|
|
||||||
var latestBlockHeight = it.getLatestBlockHeight()
|
|
||||||
if (startingBlockHeight < latestBlockHeight) {
|
|
||||||
latestBlockHeight = it.downloadMissingBlocks(startingBlockHeight, batchSize)
|
|
||||||
}
|
|
||||||
it.streamBlocks(pollFrequencyMillis, latestBlockHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return connection.subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
connection.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val DEFAULT_BATCH_SIZE = 10_000
|
|
||||||
const val DEFAULT_POLL_INTERVAL = 75_000L
|
|
||||||
}
|
|
||||||
|
|
||||||
class Connection(private val blockingStub: CompactTxStreamerBlockingStub): Closeable {
|
|
||||||
private var job: Job? = null
|
|
||||||
private var syncJob: Job? = null
|
|
||||||
private val compactBlockChannel = BroadcastChannel<CompactBlock>(100)
|
|
||||||
|
|
||||||
fun subscribe() = compactBlockChannel.openSubscription()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download all the missing blocks and return the height of the last block downloaded, which can be used to
|
|
||||||
* calculate the total number of blocks downloaded.
|
|
||||||
*/
|
|
||||||
suspend fun downloadMissingBlocks(startingBlockHeight: Long, batchSize: Int = DEFAULT_BATCH_SIZE): Long {
|
|
||||||
debug("[Downloader:${System.currentTimeMillis()}] downloadingMissingBlocks starting at $startingBlockHeight")
|
|
||||||
val latestBlockHeight = getLatestBlockHeight()
|
|
||||||
var downloadedBlockHeight = startingBlockHeight
|
|
||||||
// if blocks are missing then download them
|
|
||||||
if (startingBlockHeight < latestBlockHeight) {
|
|
||||||
val missingBlockCount = latestBlockHeight - startingBlockHeight + 1
|
|
||||||
val batches = missingBlockCount / batchSize + (if (missingBlockCount.rem(batchSize) == 0L) 0 else 1)
|
|
||||||
debug("[Downloader:${System.currentTimeMillis()}] found $missingBlockCount missing blocks, downloading in $batches batches...")
|
|
||||||
for (i in 1..batches) {
|
|
||||||
val end = Math.min(startingBlockHeight + (i * batchSize), latestBlockHeight + 1)
|
|
||||||
loadBlockRange(downloadedBlockHeight..(end-1))
|
|
||||||
downloadedBlockHeight = end
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debug("[Downloader:${System.currentTimeMillis()}] no missing blocks to download!")
|
|
||||||
}
|
|
||||||
return downloadedBlockHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getLatestBlockHeight(): Long = withContext(IO) {
|
|
||||||
blockingStub.getLatestBlock(Service.ChainSpec.newBuilder().build()).height
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun streamBlocks(pollFrequencyMillis: Long = DEFAULT_POLL_INTERVAL, startingBlockHeight: Long = Long.MAX_VALUE) = withContext(IO) {
|
|
||||||
debug("[Downloader:${System.currentTimeMillis()}] streamBlocks started at $startingBlockHeight with interval $pollFrequencyMillis")
|
|
||||||
// start with the next block, unless we were asked to start before then
|
|
||||||
var nextBlockHeight = Math.min(startingBlockHeight, getLatestBlockHeight() + 1)
|
|
||||||
while (isActive && !compactBlockChannel.isClosedForSend) {
|
|
||||||
debug("[Downloader:${System.currentTimeMillis()}] polling on thread ${Thread.currentThread().name} . . .")
|
|
||||||
val latestBlockHeight = getLatestBlockHeight()
|
|
||||||
if (latestBlockHeight >= nextBlockHeight) {
|
|
||||||
debug("[Downloader:${System.currentTimeMillis()}] found a new block! (latest: $latestBlockHeight) on thread ${Thread.currentThread().name}")
|
|
||||||
loadBlockRange(nextBlockHeight..latestBlockHeight)
|
|
||||||
nextBlockHeight = latestBlockHeight + 1
|
|
||||||
} else {
|
|
||||||
debug("[Downloader:${System.currentTimeMillis()}] no new block yet (latest: $latestBlockHeight) on thread ${Thread.currentThread().name}")
|
|
||||||
}
|
|
||||||
delay(pollFrequencyMillis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun loadBlockRange(range: LongRange): Int = withContext(IO) {
|
|
||||||
debug("[Downloader:${System.currentTimeMillis()}] requesting block range $range on thread ${Thread.currentThread().name}")
|
|
||||||
val result = blockingStub.getBlockRange(range.toBlockRange())
|
|
||||||
var resultCount = 0
|
|
||||||
while (result.hasNext()) { //hasNext blocks
|
|
||||||
resultCount++
|
|
||||||
val nextBlock = result.next()
|
|
||||||
debug("[Downloader:${System.currentTimeMillis()}] received new block: ${nextBlock.height} on thread ${Thread.currentThread().name}")
|
|
||||||
compactBlockChannel.send(nextBlock)
|
|
||||||
}
|
|
||||||
resultCount
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isClosed(): Boolean {
|
|
||||||
return compactBlockChannel.isClosedForSend
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
compactBlockChannel.cancel()
|
|
||||||
syncJob?.cancel()
|
|
||||||
syncJob = null
|
|
||||||
job?.cancel()
|
|
||||||
job = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
package cash.z.wallet.sdk.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import cash.z.wallet.sdk.dao.CompactBlockDao
|
||||||
|
import cash.z.wallet.sdk.db.CompactBlockDb
|
||||||
|
import cash.z.wallet.sdk.exception.CompactBlockProcessorException
|
||||||
|
import cash.z.wallet.sdk.jni.JniConverter
|
||||||
|
import cash.z.wallet.sdk.rpc.CompactFormats
|
||||||
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.properties.ReadOnlyProperty
|
||||||
|
import kotlin.properties.ReadWriteProperty
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsible for processing the blocks on the stream. Saves them to the cacheDb and periodically scans for transactions.
|
||||||
|
*
|
||||||
|
* @property applicationContext used to connect to the DB on the device. No reference is kept beyond construction.
|
||||||
|
* @property seedProvider used for scanning. Later, this will be replaced by a viewing key so we don't pass the seed around.
|
||||||
|
*/
|
||||||
|
class CompactBlockProcessor(
|
||||||
|
applicationContext: Context,
|
||||||
|
val converter: JniConverter = JniConverter(),
|
||||||
|
cacheDbName: String = CACHE_DB_NAME,
|
||||||
|
dataDbName: String = DATA_DB_NAME,
|
||||||
|
seedProvider: ReadOnlyProperty<Any?, ByteArray> = SampleSeedProvider("dummyseed"),
|
||||||
|
logger: Twig = SilentTwig()
|
||||||
|
) : Twig by logger {
|
||||||
|
|
||||||
|
internal val cacheDao: CompactBlockDao
|
||||||
|
private val cacheDb: CompactBlockDb
|
||||||
|
private val cacheDbPath: String
|
||||||
|
private val dataDbPath: String
|
||||||
|
|
||||||
|
private val seed by seedProvider
|
||||||
|
var birthdayHeight = Long.MAX_VALUE
|
||||||
|
|
||||||
|
internal val dataDbExists get() = File(dataDbPath).exists()
|
||||||
|
|
||||||
|
init {
|
||||||
|
cacheDb = createCompactBlockCacheDb(applicationContext, cacheDbName)
|
||||||
|
cacheDao = cacheDb.complactBlockDao()
|
||||||
|
cacheDbPath = applicationContext.getDatabasePath(cacheDbName).absolutePath
|
||||||
|
dataDbPath = applicationContext.getDatabasePath(dataDbName).absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onFirstRun() {
|
||||||
|
twigTask("executing compactblock processor for first run: initializing data db") {
|
||||||
|
converter.initDataDb(dataDbPath)
|
||||||
|
}
|
||||||
|
// TODO: add precomputed sapling tree to DB and this will be the basis for the birthday
|
||||||
|
// val birthday = 373070L
|
||||||
|
val birthday = 394925L
|
||||||
|
birthdayHeight = birthday
|
||||||
|
twig("compactblock processor birthday set to $birthdayHeight")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCompactBlockCacheDb(applicationContext: Context, cacheDbName: String): CompactBlockDb {
|
||||||
|
return Room.databaseBuilder(applicationContext, CompactBlockDb::class.java, cacheDbName)
|
||||||
|
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
|
||||||
|
// this is a simple cache of blocks. destroying the db should be benign
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save blocks and periodically scan them.
|
||||||
|
*/
|
||||||
|
suspend fun processBlocks(incomingBlocks: ReceiveChannel<CompactFormats.CompactBlock>) = withContext(IO) {
|
||||||
|
ensureDataDb()
|
||||||
|
twigTask("processing blocks") {
|
||||||
|
var lastScanTime = System.currentTimeMillis()
|
||||||
|
var hasScanned = false
|
||||||
|
while (isActive && !incomingBlocks.isClosedForReceive) {
|
||||||
|
twig("awaiting next block")
|
||||||
|
val nextBlock = incomingBlocks.receive()
|
||||||
|
val nextBlockHeight = nextBlock.height
|
||||||
|
twig("received block with height ${nextBlockHeight} on thread ${Thread.currentThread().name}")
|
||||||
|
if (birthdayHeight > nextBlockHeight) {
|
||||||
|
birthdayHeight = nextBlockHeight
|
||||||
|
twig("birthday initialized to $birthdayHeight")
|
||||||
|
}
|
||||||
|
cacheDao.insert(cash.z.wallet.sdk.vo.CompactBlock(nextBlockHeight.toInt(), nextBlock.toByteArray()))
|
||||||
|
if (shouldScanBlocks(lastScanTime, hasScanned)) {
|
||||||
|
twig("last block prior to scan ${nextBlockHeight}")
|
||||||
|
scanBlocks()
|
||||||
|
lastScanTime = System.currentTimeMillis()
|
||||||
|
hasScanned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cacheDb.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureDataDb() {
|
||||||
|
if (!dataDbExists) throw CompactBlockProcessorException.DataDbMissing(dataDbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldScanBlocks(lastScanTime: Long, hasScanned: Boolean): Boolean {
|
||||||
|
val deltaTime = System.currentTimeMillis() - lastScanTime
|
||||||
|
twig("${deltaTime}ms since last scan. Have we ever scanned? $hasScanned")
|
||||||
|
return (!hasScanned && deltaTime > INITIAL_SCAN_DELAY)
|
||||||
|
|| deltaTime > SCAN_FREQUENCY
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun scanBlocks() = withContext(IO) {
|
||||||
|
twigTask("scanning blocks") {
|
||||||
|
if (isActive) {
|
||||||
|
try {
|
||||||
|
converter.scanBlocks(
|
||||||
|
cacheDbPath,
|
||||||
|
dataDbPath,
|
||||||
|
seed,
|
||||||
|
birthdayHeight.toInt()
|
||||||
|
)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
twig("error while scanning blocks: $t")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Default amount of time to synchronize before initiating the first scan. This allows time to download a few blocks. */
|
||||||
|
const val INITIAL_SCAN_DELAY = 3000L
|
||||||
|
/** Minimum amount of time between scans. The frequency with which we check whether the block height has changed and, if so, trigger a scan */
|
||||||
|
const val SCAN_FREQUENCY = 75_000L
|
||||||
|
const val CACHE_DB_NAME = "DownloadedCompactBlocks.db"
|
||||||
|
const val DATA_DB_NAME = "CompactBlockScanResults.db"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,207 @@
|
||||||
|
package cash.z.wallet.sdk.data
|
||||||
|
|
||||||
|
import cash.z.wallet.sdk.exception.CompactBlockStreamException
|
||||||
|
import cash.z.wallet.sdk.ext.toBlockRange
|
||||||
|
import cash.z.wallet.sdk.rpc.CompactFormats.CompactBlock
|
||||||
|
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc
|
||||||
|
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc.CompactTxStreamerBlockingStub
|
||||||
|
import cash.z.wallet.sdk.rpc.Service
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
import io.grpc.Channel
|
||||||
|
import io.grpc.ManagedChannelBuilder
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
|
import kotlinx.coroutines.channels.BroadcastChannel
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.channels.distinct
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves as a source of compact blocks received from the light wallet server. Once started, it will
|
||||||
|
* request all the appropriate blocks and then stream them into the channel returned when calling [start].
|
||||||
|
*/
|
||||||
|
class CompactBlockStream private constructor(logger: Twig = SilentTwig()) : Twig by logger {
|
||||||
|
lateinit var connection: Connection
|
||||||
|
|
||||||
|
// TODO: improve the creation of this channel (tweak its settings to use mobile device responsibly) and make sure it is properly cleaned up
|
||||||
|
constructor(host: String, port: Int, logger: Twig = SilentTwig()) : this(
|
||||||
|
ManagedChannelBuilder.forAddress(host, port).usePlaintext().build(), logger
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(channel: Channel, logger: Twig = SilentTwig()) : this(logger) {
|
||||||
|
connection = Connection(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
startingBlockHeight: Long = Long.MAX_VALUE,
|
||||||
|
batchSize: Int = DEFAULT_BATCH_SIZE,
|
||||||
|
pollFrequencyMillis: Long = DEFAULT_POLL_INTERVAL
|
||||||
|
): ReceiveChannel<CompactBlock> {
|
||||||
|
if(connection.isClosed()) throw CompactBlockStreamException.ConnectionClosed
|
||||||
|
twig("starting")
|
||||||
|
scope.launch {
|
||||||
|
twig("preparing to stream blocks...")
|
||||||
|
delay(1000L) // TODO: we can probably get rid of this delay.
|
||||||
|
try {
|
||||||
|
connection.use {
|
||||||
|
twig("requesting latest block height")
|
||||||
|
var latestBlockHeight = it.getLatestBlockHeight()
|
||||||
|
twig("responded with latest block height of $latestBlockHeight")
|
||||||
|
if (startingBlockHeight < latestBlockHeight) {
|
||||||
|
twig("downloading missing blocks from $startingBlockHeight to $latestBlockHeight")
|
||||||
|
latestBlockHeight = it.downloadMissingBlocks(startingBlockHeight, batchSize)
|
||||||
|
twig("done downloading missing blocks")
|
||||||
|
}
|
||||||
|
it.streamBlocks(pollFrequencyMillis, latestBlockHeight)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun progress() = connection.progress().distinct()
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
twig("stopping")
|
||||||
|
connection.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_BATCH_SIZE = 10_000
|
||||||
|
const val DEFAULT_POLL_INTERVAL = 75_000L
|
||||||
|
const val DEFAULT_RETRIES = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class Connection(private val channel: Channel): Closeable {
|
||||||
|
private var job: Job? = null
|
||||||
|
private var syncJob: Job? = null
|
||||||
|
private val compactBlockChannel = BroadcastChannel<CompactBlock>(100)
|
||||||
|
private val progressChannel = BroadcastChannel<Int>(100)
|
||||||
|
|
||||||
|
fun createStub(timeoutMillis: Long = 60_000L): CompactTxStreamerBlockingStub {
|
||||||
|
return CompactTxStreamerGrpc.newBlockingStub(channel).withDeadlineAfter(timeoutMillis, TimeUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe() = compactBlockChannel.openSubscription()
|
||||||
|
|
||||||
|
fun progress() = progressChannel.openSubscription()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download all the missing blocks and return the height of the last block downloaded, which can be used to
|
||||||
|
* calculate the total number of blocks downloaded.
|
||||||
|
*/
|
||||||
|
suspend fun downloadMissingBlocks(startingBlockHeight: Long, batchSize: Int = DEFAULT_BATCH_SIZE): Long {
|
||||||
|
twig("downloadingMissingBlocks starting at $startingBlockHeight")
|
||||||
|
val latestBlockHeight = getLatestBlockHeight()
|
||||||
|
var downloadedBlockHeight = startingBlockHeight
|
||||||
|
// if blocks are missing then download them
|
||||||
|
if (startingBlockHeight < latestBlockHeight) {
|
||||||
|
val missingBlockCount = latestBlockHeight - startingBlockHeight + 1
|
||||||
|
val batches = missingBlockCount / batchSize + (if (missingBlockCount.rem(batchSize) == 0L) 0 else 1)
|
||||||
|
var progress: Int
|
||||||
|
twig("found $missingBlockCount missing blocks, downloading in $batches batches...")
|
||||||
|
for (i in 1..batches) {
|
||||||
|
retryUpTo(DEFAULT_RETRIES) {
|
||||||
|
twig("beginning batch $i")
|
||||||
|
val end = Math.min(startingBlockHeight + (i * batchSize), latestBlockHeight + 1)
|
||||||
|
loadBlockRange(downloadedBlockHeight..(end-1))
|
||||||
|
progress = Math.round(i/batches.toFloat() * 100)
|
||||||
|
progressChannel.send(progress)
|
||||||
|
downloadedBlockHeight = end
|
||||||
|
twig("finished batch $i\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progressChannel.cancel()
|
||||||
|
} else {
|
||||||
|
twig("no missing blocks to download!")
|
||||||
|
}
|
||||||
|
return downloadedBlockHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLatestBlockHeight(): Long = withContext(IO) {
|
||||||
|
createStub().getLatestBlock(Service.ChainSpec.newBuilder().build()).height
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun submitTransaction(raw: ByteArray) = withContext(IO) {
|
||||||
|
val request = Service.RawTransaction.newBuilder().setData(ByteString.copyFrom(raw)).build()
|
||||||
|
createStub().sendTransaction(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun streamBlocks(pollFrequencyMillis: Long = DEFAULT_POLL_INTERVAL, startingBlockHeight: Long = Long.MAX_VALUE) = withContext(IO) {
|
||||||
|
twig("streamBlocks started at $startingBlockHeight with interval $pollFrequencyMillis")
|
||||||
|
// start with the next block, unless we were asked to start before then
|
||||||
|
var nextBlockHeight = Math.min(startingBlockHeight, getLatestBlockHeight() + 1)
|
||||||
|
while (isActive && !compactBlockChannel.isClosedForSend) {
|
||||||
|
retryUpTo(DEFAULT_RETRIES) {
|
||||||
|
twig("polling for next block in stream on thread ${Thread.currentThread().name} . . .")
|
||||||
|
val latestBlockHeight = getLatestBlockHeight()
|
||||||
|
if (latestBlockHeight >= nextBlockHeight) {
|
||||||
|
twig("found a new block! (latest: $latestBlockHeight) on thread ${Thread.currentThread().name}")
|
||||||
|
loadBlockRange(nextBlockHeight..latestBlockHeight)
|
||||||
|
nextBlockHeight = latestBlockHeight + 1
|
||||||
|
} else {
|
||||||
|
twig("no new block yet (latest: $latestBlockHeight) on thread ${Thread.currentThread().name}")
|
||||||
|
}
|
||||||
|
twig("delaying $pollFrequencyMillis before polling for next block in stream")
|
||||||
|
delay(pollFrequencyMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun retryUpTo(retries: Int, initialDelay:Int = 10, block: suspend () -> Unit) {
|
||||||
|
var failedAttempts = 0
|
||||||
|
while (failedAttempts < retries) {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
return
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
failedAttempts++
|
||||||
|
if (failedAttempts >= retries) throw t
|
||||||
|
val duration = Math.pow(initialDelay.toDouble(), failedAttempts.toDouble()).toLong()
|
||||||
|
twig("failed due to $t retrying (${failedAttempts+1}/$retries) in ${duration}s...")
|
||||||
|
delay(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadBlockRange(range: LongRange): Int = withContext(IO) {
|
||||||
|
twig("requesting block range $range on thread ${Thread.currentThread().name}")
|
||||||
|
val result = createStub(90_000L).getBlockRange(range.toBlockRange())
|
||||||
|
twig("done requesting block range")
|
||||||
|
var resultCount = 0
|
||||||
|
while (checkNextBlock(result)) { //calls result.hasNext, which blocks because we use a blockingStub
|
||||||
|
resultCount++
|
||||||
|
val nextBlock = result.next()
|
||||||
|
twig("...while loading block range $range, received new block ${nextBlock.height} on thread ${Thread.currentThread().name}. Sending...")
|
||||||
|
compactBlockChannel.send(nextBlock)
|
||||||
|
twig("...done sending block ${nextBlock.height}")
|
||||||
|
}
|
||||||
|
twig("done loading block range $range")
|
||||||
|
resultCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/* this helper method is used to allow for logic (like logging) before blocking on the current thread */
|
||||||
|
private fun checkNextBlock(result: MutableIterator<CompactBlock>): Boolean {
|
||||||
|
twig("awaiting next block...")
|
||||||
|
return result.hasNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isClosed(): Boolean {
|
||||||
|
return compactBlockChannel.isClosedForSend
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
compactBlockChannel.cancel()
|
||||||
|
progressChannel.cancel()
|
||||||
|
syncJob?.cancel()
|
||||||
|
syncJob = null
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,191 @@
|
||||||
|
package cash.z.wallet.sdk.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import cash.z.wallet.sdk.dao.BlockDao
|
||||||
|
import cash.z.wallet.sdk.dao.NoteDao
|
||||||
|
import cash.z.wallet.sdk.dao.TransactionDao
|
||||||
|
import cash.z.wallet.sdk.db.DerivedDataDb
|
||||||
|
import cash.z.wallet.sdk.exception.RepositoryException
|
||||||
|
import cash.z.wallet.sdk.exception.RustLayerException
|
||||||
|
import cash.z.wallet.sdk.jni.JniConverter
|
||||||
|
import cash.z.wallet.sdk.vo.NoteQuery
|
||||||
|
import cash.z.wallet.sdk.vo.Transaction
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
|
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.channels.distinct
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository that does polling for simplicity. We will implement an alternative version that uses live data as well as
|
||||||
|
* one that creates triggers and then reference them here. For now this is the most basic example of keeping track of
|
||||||
|
* changes.
|
||||||
|
*/
|
||||||
|
open class PollingTransactionRepository(
|
||||||
|
private val derivedDataDb: DerivedDataDb,
|
||||||
|
private val derivedDataDbPath: String,
|
||||||
|
private val converter: JniConverter,
|
||||||
|
private val pollFrequencyMillis: Long = 2000L,
|
||||||
|
logger: Twig = SilentTwig()
|
||||||
|
) : TransactionRepository, Twig by logger {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor that creates the database and then executes a callback on it.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
context: Context,
|
||||||
|
dataDbName: String,
|
||||||
|
pollFrequencyMillis: Long = 2000L,
|
||||||
|
converter: JniConverter = JniConverter(),
|
||||||
|
logger: Twig = SilentTwig(),
|
||||||
|
dbCallback: (DerivedDataDb) -> Unit = {}
|
||||||
|
) : this(
|
||||||
|
Room.databaseBuilder(context, DerivedDataDb::class.java, dataDbName)
|
||||||
|
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build(),
|
||||||
|
context.getDatabasePath(dataDbName).absolutePath,
|
||||||
|
converter,
|
||||||
|
pollFrequencyMillis,
|
||||||
|
logger
|
||||||
|
) {
|
||||||
|
dbCallback(derivedDataDb)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val notes: NoteDao = derivedDataDb.noteDao()
|
||||||
|
internal val blocks: BlockDao = derivedDataDb.blockDao()
|
||||||
|
private val transactions: TransactionDao = derivedDataDb.transactionDao()
|
||||||
|
private lateinit var pollingJob: Job
|
||||||
|
private val balanceChannel = ConflatedBroadcastChannel<Long>()
|
||||||
|
private val allTransactionsChannel = ConflatedBroadcastChannel<List<NoteQuery>>()
|
||||||
|
val existingTransactions = listOf<NoteQuery>()
|
||||||
|
private val wasPreviouslyStarted
|
||||||
|
get() = !existingTransactions.isEmpty() || balanceChannel.isClosedForSend || allTransactionsChannel.isClosedForSend
|
||||||
|
|
||||||
|
override fun start(parentScope: CoroutineScope) {
|
||||||
|
// prevent restarts so the behavior of this class is easier to reason about
|
||||||
|
if (wasPreviouslyStarted) throw RepositoryException.FalseStart
|
||||||
|
|
||||||
|
twig("starting")
|
||||||
|
|
||||||
|
pollingJob = parentScope.launch {
|
||||||
|
poll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
twig("stopping")
|
||||||
|
balanceChannel.cancel()
|
||||||
|
allTransactionsChannel.cancel()
|
||||||
|
pollingJob.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun balance(): ReceiveChannel<Long> {
|
||||||
|
return balanceChannel.openSubscription().distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun allTransactions(): ReceiveChannel<List<NoteQuery>> {
|
||||||
|
return allTransactionsChannel.openSubscription()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun lastScannedHeight(): Long {
|
||||||
|
return blocks.lastScannedHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findTransactionById(txId: Long): Transaction? = withContext(IO) {
|
||||||
|
twig("finding transaction with id $txId on thread ${Thread.currentThread().name}")
|
||||||
|
val transaction = transactions.findById(txId)
|
||||||
|
twig("found ${transaction?.id}")
|
||||||
|
transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteTransactionById(txId: Long) = withContext(IO) {
|
||||||
|
twigTask("deleting transaction with id ${txId}") {
|
||||||
|
transactions.deleteById(txId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun poll() = withContext(IO) {
|
||||||
|
try {
|
||||||
|
var previousNotes: List<NoteQuery>? = null
|
||||||
|
while (isActive
|
||||||
|
&& !balanceChannel.isClosedForSend
|
||||||
|
&& !allTransactionsChannel.isClosedForSend
|
||||||
|
) {
|
||||||
|
twigTask("polling for transactions") {
|
||||||
|
val newNotes = notes.getAll()
|
||||||
|
|
||||||
|
if (hasChanged(previousNotes, newNotes)) {
|
||||||
|
twig("loaded ${notes.count()} transactions and changes were detected!")
|
||||||
|
allTransactionsChannel.send(newNotes)
|
||||||
|
sendLatestBalance()
|
||||||
|
previousNotes = newNotes
|
||||||
|
} else {
|
||||||
|
twig("loaded ${notes.count()} transactions but no changes detected.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(pollFrequencyMillis)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasChanged(oldNotes: List<NoteQuery>?, newNotes: List<NoteQuery>): Boolean {
|
||||||
|
// shortcuts first
|
||||||
|
if (newNotes.isEmpty() && oldNotes == null) return false // if nothing has happened, that doesn't count as a change
|
||||||
|
if (oldNotes == null) return true
|
||||||
|
if (oldNotes.size != newNotes.size) return true
|
||||||
|
|
||||||
|
for (note in newNotes) {
|
||||||
|
if (!oldNotes.contains(note)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// private suspend fun poll() = withContext(IO) {
|
||||||
|
// try {
|
||||||
|
// while (isActive && !transactionChannel.isClosedForSend && !balanceChannel.isClosedForSend && !allTransactionsChannel.isClosedForSend) {
|
||||||
|
// twigTask("polling for transactions") {
|
||||||
|
// val newTransactions = checkForNewTransactions()
|
||||||
|
// newTransactions?.takeUnless { it.isEmpty() }?.forEach {
|
||||||
|
// existingTransactions.union(listOf(it))
|
||||||
|
// transactionChannel.send(it)
|
||||||
|
// allTransactionsChannel.send(existingTransactions)
|
||||||
|
// }?.also {
|
||||||
|
// twig("discovered ${newTransactions?.size} transactions!")
|
||||||
|
// // only update the balance when we've had some new transactions
|
||||||
|
// sendLatestBalance()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// delay(pollFrequencyMillis)
|
||||||
|
// }
|
||||||
|
// } finally {
|
||||||
|
// // if the job is cancelled, it should be the same as the repository stopping.
|
||||||
|
// // otherwise, it over-complicates things and makes it harder to reason about the behavior of this class.
|
||||||
|
// stop()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// protected open fun checkForNewTransactions(): Set<NoteQuery>? {
|
||||||
|
// val notes = notes.getAll()
|
||||||
|
// twig("object $this : checking for new transactions. previousCount: ${existingTransactions.size} currentCount: ${notes.size}")
|
||||||
|
// return notes.subtract(existingTransactions)
|
||||||
|
// }
|
||||||
|
|
||||||
|
private suspend fun sendLatestBalance() = withContext(IO) {
|
||||||
|
twigTask("sending balance") {
|
||||||
|
try {
|
||||||
|
val balance = converter.getBalance(derivedDataDbPath)
|
||||||
|
twig("balance: $balance")
|
||||||
|
balanceChannel.send(balance)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
twig("failed to get balance due to $t")
|
||||||
|
throw RustLayerException.BalanceException(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package cash.z.wallet.sdk.data
|
||||||
|
|
||||||
|
import kotlin.properties.ReadOnlyProperty
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
class SampleSeedProvider(val seedValue: String) : ReadOnlyProperty<Any?, ByteArray> {
|
||||||
|
override fun getValue(thisRef: Any?, property: KProperty<*>): ByteArray {
|
||||||
|
return seedValue.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,68 +0,0 @@
|
||||||
package cash.z.wallet.sdk.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
|
||||||
|
|
||||||
class ScanResultDbCreator(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
|
|
||||||
override fun onCreate(db: SQLiteDatabase) {
|
|
||||||
SQL_CREATE_DB.split(";").forEach { db.execSQL(it.trim()) }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
|
||||||
onCreate(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
|
||||||
onUpgrade(db, oldVersion, newVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val DB_NAME = "ScannedBlockResults.db"
|
|
||||||
const val DB_VERSION = 1
|
|
||||||
val SQL_CREATE_DB: String = """
|
|
||||||
CREATE TABLE IF NOT EXISTS blocks (
|
|
||||||
height INTEGER PRIMARY KEY,
|
|
||||||
time INTEGER,
|
|
||||||
sapling_tree BLOB
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS transactions (
|
|
||||||
id_tx INTEGER PRIMARY KEY,
|
|
||||||
txid BLOB NOT NULL UNIQUE,
|
|
||||||
block INTEGER,
|
|
||||||
raw BLOB,
|
|
||||||
FOREIGN KEY (block) REFERENCES blocks(height)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS received_notes (
|
|
||||||
id_note INTEGER PRIMARY KEY,
|
|
||||||
tx INTEGER NOT NULL,
|
|
||||||
output_index INTEGER NOT NULL,
|
|
||||||
account INTEGER NOT NULL,
|
|
||||||
diversifier BLOB NOT NULL,
|
|
||||||
value INTEGER NOT NULL,
|
|
||||||
rcm BLOB NOT NULL,
|
|
||||||
nf BLOB NOT NULL UNIQUE,
|
|
||||||
memo BLOB,
|
|
||||||
spent INTEGER,
|
|
||||||
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
|
|
||||||
FOREIGN KEY (spent) REFERENCES transactions(id_tx),
|
|
||||||
CONSTRAINT tx_output UNIQUE (tx, output_index)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS sapling_witnesses (
|
|
||||||
id_witness INTEGER PRIMARY KEY,
|
|
||||||
note INTEGER NOT NULL,
|
|
||||||
block INTEGER NOT NULL,
|
|
||||||
witness BLOB NOT NULL,
|
|
||||||
FOREIGN KEY (note) REFERENCES received_notes(id_note),
|
|
||||||
FOREIGN KEY (block) REFERENCES blocks(height),
|
|
||||||
CONSTRAINT witness_height UNIQUE (note, block)
|
|
||||||
)
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
fun create(context: Context) {
|
|
||||||
val db = ScanResultDbCreator(context).writableDatabase
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
package cash.z.wallet.sdk.data
|
||||||
|
|
||||||
|
import kotlin.properties.ReadWriteProperty
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
class SimpleProvider<T>(var value: T) : ReadWriteProperty<Any?, T> {
|
||||||
|
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
|
||||||
|
this.value = value
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,104 +1,115 @@
|
||||||
package cash.z.wallet.sdk.data
|
package cash.z.wallet.sdk.data
|
||||||
|
|
||||||
import android.content.Context
|
import cash.z.wallet.sdk.data.Synchronizer.SyncState.FirstRun
|
||||||
import androidx.room.Room
|
import cash.z.wallet.sdk.data.Synchronizer.SyncState.ReadyToProcess
|
||||||
import androidx.room.RoomDatabase
|
import cash.z.wallet.sdk.exception.SynchronizerException
|
||||||
import cash.z.wallet.sdk.dao.CompactBlockDao
|
import cash.z.wallet.sdk.rpc.CompactFormats
|
||||||
import cash.z.wallet.sdk.db.CompactBlockDb
|
import cash.z.wallet.sdk.secure.Wallet
|
||||||
import cash.z.wallet.sdk.ext.debug
|
|
||||||
import cash.z.wallet.sdk.jni.JniConverter
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
import cash.z.wallet.sdk.rpc.CompactFormats
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads compact blocks to the database and then scans them for transactions
|
* The glue. Downloads compact blocks to the database and then scans them for transactions. In order to serve that
|
||||||
|
* purpose, this class glues together a variety of key components. Each component contributes to the team effort of
|
||||||
|
* providing a simple source of truth to interact with.
|
||||||
|
*
|
||||||
|
* Another way of thinking about this class is the reference that demonstrates how all the pieces can be tied
|
||||||
|
* together.
|
||||||
*/
|
*/
|
||||||
class Synchronizer(val applicationContext: Context, val scope: CoroutineScope, val birthday: Long = 373070L) {
|
class Synchronizer(
|
||||||
|
val downloader: CompactBlockStream,
|
||||||
|
val processor: CompactBlockProcessor,
|
||||||
|
val repository: TransactionRepository,
|
||||||
|
val wallet: Wallet,
|
||||||
|
val batchSize: Int = 1000,
|
||||||
|
logger: Twig = SilentTwig()
|
||||||
|
) : Twig by logger {
|
||||||
|
|
||||||
// TODO: convert to CompactBlockSource that just has a stream and then have the downloader operate on the stream
|
// private val downloader = CompactBlockDownloader("10.0.2.2", 9067)
|
||||||
private val downloader = CompactBlockDownloader("10.0.2.2", 9067)
|
|
||||||
private val savedBlockChannel = ConflatedBroadcastChannel<CompactFormats.CompactBlock>()
|
private val savedBlockChannel = ConflatedBroadcastChannel<CompactFormats.CompactBlock>()
|
||||||
private lateinit var cacheDao: CompactBlockDao
|
|
||||||
private lateinit var cacheDb: CompactBlockDb
|
private lateinit var blockJob: Job
|
||||||
private lateinit var saveJob: Job
|
|
||||||
private lateinit var scanJob: Job
|
private val wasPreviouslyStarted
|
||||||
|
get() = savedBlockChannel.isClosedForSend || ::blockJob.isInitialized
|
||||||
|
|
||||||
fun blocks(): ReceiveChannel<CompactFormats.CompactBlock> = savedBlockChannel.openSubscription()
|
fun blocks(): ReceiveChannel<CompactFormats.CompactBlock> = savedBlockChannel.openSubscription()
|
||||||
|
|
||||||
fun start() {
|
fun start(parentScope: CoroutineScope): Synchronizer {
|
||||||
createDb()
|
// prevent restarts so the behavior of this class is easier to reason about
|
||||||
downloader.start(scope, birthday)
|
if (wasPreviouslyStarted) throw SynchronizerException.FalseStart
|
||||||
saveJob = saveBlocks()
|
twig("starting")
|
||||||
scanJob = scanBlocks()
|
blockJob = parentScope.launch {
|
||||||
|
continueWithState(determineState())
|
||||||
|
}
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun CoroutineScope.continueWithState(syncState: SyncState): Job {
|
||||||
scanJob.cancel()
|
return when (syncState) {
|
||||||
saveJob.cancel()
|
FirstRun -> onFirstRun()
|
||||||
downloader.stop()
|
is ReadyToProcess -> onReady(syncState)
|
||||||
cacheDb.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createDb() {
|
|
||||||
// TODO: inject the db and dao
|
|
||||||
cacheDb = Room.databaseBuilder(
|
|
||||||
applicationContext,
|
|
||||||
CompactBlockDb::class.java,
|
|
||||||
CACHEDB_NAME
|
|
||||||
)
|
|
||||||
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
|
|
||||||
.fallbackToDestructiveMigration()
|
|
||||||
.build()
|
|
||||||
.apply { cacheDao = complactBlockDao() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveBlocks(): Job = scope.launch {
|
|
||||||
// val downloadedBlockChannel = downloader.blocks()
|
|
||||||
// while (isActive) {
|
|
||||||
// try {
|
|
||||||
// val nextBlock = downloadedBlockChannel.receive()
|
|
||||||
// cacheDao.insert(cash.z.wallet.sdk.vo.CompactBlock(nextBlock.height.toInt(), nextBlock.toByteArray()))
|
|
||||||
// async {
|
|
||||||
// savedBlockChannel.send(Result.success(nextBlock))
|
|
||||||
// debug("stored block at height: ${nextBlock.height}")
|
|
||||||
// }
|
|
||||||
// } catch (t: Throwable) {
|
|
||||||
// debug("failed to store block due to $t")
|
|
||||||
// async {
|
|
||||||
// savedBlockChannel.send(Result.failure(t))
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scanBlocks(): Job = scope.launch {
|
|
||||||
val savedBlocks = blocks()
|
|
||||||
val converter = JniConverter()
|
|
||||||
converter.initLogs()
|
|
||||||
ScanResultDbCreator.create(applicationContext)
|
|
||||||
while (isActive) {
|
|
||||||
try {
|
|
||||||
debug("scanning blocks from $birthday onward...")
|
|
||||||
val nextBlock = savedBlocks.receive()
|
|
||||||
debug("...scanner observed a block (${nextBlock.height}) without crashing!")
|
|
||||||
delay(5000L)
|
|
||||||
val result = converter.scanBlocks(
|
|
||||||
applicationContext.getDatabasePath(CACHEDB_NAME).absolutePath,
|
|
||||||
applicationContext.getDatabasePath(ScanResultDbCreator.DB_NAME).absolutePath,
|
|
||||||
"dummyseed".toByteArray(),
|
|
||||||
birthday.toInt()
|
|
||||||
)
|
|
||||||
debug("scan complete")
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
debug("error while scanning blocks: $t")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private fun CoroutineScope.onFirstRun(): Job {
|
||||||
const val CACHEDB_NAME = "DownloadedCompactBlocks.db"
|
twig("this appears to be a fresh install, beginning first run of application")
|
||||||
|
processor.onFirstRun()
|
||||||
|
return continueWithState(ReadyToProcess(processor.birthdayHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.onReady(syncState: ReadyToProcess) = launch {
|
||||||
|
twig("synchronization is ready to begin at height ${syncState.startingBlockHeight}")
|
||||||
|
try {
|
||||||
|
// TODO: for PIR concerns, introduce some jitter here for where, exactly, the downloader starts
|
||||||
|
val blockChannel =
|
||||||
|
downloader.start(this, syncState.startingBlockHeight, batchSize)
|
||||||
|
repository.start(this)
|
||||||
|
processor.processBlocks(blockChannel)
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: get rid of this temporary helper function after syncing with the latest rust code
|
||||||
|
suspend fun updateTimeStamp(height: Int): Long? = withContext(IO) {
|
||||||
|
val originalBlock = processor.cacheDao.findById(height)
|
||||||
|
twig("TMP: found block at height ${height}")
|
||||||
|
if (originalBlock != null) {
|
||||||
|
val ogBlock = CompactFormats.CompactBlock.parseFrom(originalBlock.data)
|
||||||
|
twig("TMP: parsed block! ${ogBlock.height} ${ogBlock.time}")
|
||||||
|
(repository as PollingTransactionRepository).blocks.updateTime(height, ogBlock.time)
|
||||||
|
ogBlock.time
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun determineState(): SyncState = withContext(IO) {
|
||||||
|
twig("determining state (has the app run before, what block did we last see, etc.)")
|
||||||
|
val state = if (processor.dataDbExists) {
|
||||||
|
// this call blocks because it does IO
|
||||||
|
val startingBlockHeight = repository.lastScannedHeight()
|
||||||
|
twig("dataDb exists with last height of $startingBlockHeight")
|
||||||
|
if (startingBlockHeight == 0L) FirstRun else ReadyToProcess(startingBlockHeight)
|
||||||
|
} else {
|
||||||
|
FirstRun
|
||||||
|
}
|
||||||
|
|
||||||
|
twig("determined ${state::class.java.simpleName}")
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
twig("stopping")
|
||||||
|
blockJob.cancel()
|
||||||
|
downloader.stop()
|
||||||
|
repository.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SyncState {
|
||||||
|
object FirstRun : SyncState()
|
||||||
|
class ReadyToProcess(val startingBlockHeight: Long = Long.MAX_VALUE) : SyncState()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package cash.z.wallet.sdk.data
|
||||||
|
|
||||||
|
import cash.z.wallet.sdk.vo.NoteQuery
|
||||||
|
import cash.z.wallet.sdk.vo.Transaction
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
interface TransactionRepository {
|
||||||
|
fun start(parentScope: CoroutineScope)
|
||||||
|
fun stop()
|
||||||
|
fun balance(): ReceiveChannel<Long>
|
||||||
|
fun allTransactions(): ReceiveChannel<List<NoteQuery>>
|
||||||
|
fun lastScannedHeight(): Long
|
||||||
|
suspend fun findTransactionById(txId: Long): Transaction?
|
||||||
|
suspend fun deleteTransactionById(txId: Long)
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
package cash.z.wallet.sdk.data
|
||||||
|
|
||||||
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny log.
|
||||||
|
*/
|
||||||
|
interface Twig {
|
||||||
|
fun twig(logMessage: String = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny log that does nothing. No one hears this twig fall in the woods.
|
||||||
|
*/
|
||||||
|
class SilentTwig : Twig {
|
||||||
|
override fun twig(logMessage: String) {
|
||||||
|
// shh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny log for detecting troubles. Aim at your troubles and pull the twigger.
|
||||||
|
*
|
||||||
|
* @param formatter a formatter for the twigs. The default one is pretty spiffy.
|
||||||
|
* @param printer a printer for the twigs. The default is System.err.println.
|
||||||
|
*/
|
||||||
|
open class TroubleshootingTwig(
|
||||||
|
val formatter: (String) -> String = spiffy(5),
|
||||||
|
val printer: (String) -> Any = System.err::println
|
||||||
|
) : Twig {
|
||||||
|
override fun twig(logMessage: String) {
|
||||||
|
printer(formatter(logMessage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny log task. Execute the block of code with some twigging around the outside.
|
||||||
|
*/
|
||||||
|
// for silent twigs, this adds a small amount of overhead at the call site but still avoids logging
|
||||||
|
//
|
||||||
|
// note: being an extension function (i.e. static rather than a member of the Twig interface) allows this function to be
|
||||||
|
// inlined and simplifies its use with suspend functions
|
||||||
|
// (otherwise the function and its "block" param would have to suspend)
|
||||||
|
inline fun Twig.twigTask(logMessage: String, block: () -> Unit) {
|
||||||
|
twig("$logMessage - started | on thread ${Thread.currentThread().name})")
|
||||||
|
val time = measureTimeMillis(block)
|
||||||
|
twig("$logMessage - completed | in ${time}ms on thread ${Thread.currentThread().name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny log formatter that makes twigs pretty spiffy.
|
||||||
|
*
|
||||||
|
* @param stackFrame the stack frame from which we try to derive the class. This can vary depending on how the code is
|
||||||
|
* called so we expose it for flexibility. Jiggle the handle on this whenever the line numbers appear incorrect.
|
||||||
|
*/
|
||||||
|
inline fun spiffy(stackFrame: Int = 4, tag: String = "@TWIG"): (String) -> String = { logMessage: String ->
|
||||||
|
val stack = Thread.currentThread().stackTrace[stackFrame]
|
||||||
|
val time = String.format("${tag} %1\$tD %1\$tI:%1\$tM:%1\$tS.%1\$tN", System.currentTimeMillis())
|
||||||
|
val className = stack.className.split(".").lastOrNull()?.split("\$")?.firstOrNull()
|
||||||
|
"$time[$className:${stack.lineNumber}] $logMessage"
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
package cash.z.wallet.sdk.exception
|
||||||
|
|
||||||
|
import java.lang.RuntimeException
|
||||||
|
|
||||||
|
//TODO: rename things in here when we know what we're calling the Rust layer (librustzcash?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exceptions thrown in the Rust layer of the SDK. We may not always be able to surface details about this
|
||||||
|
* exception so it's important for the SDK to provide helpful messages whenever these errors are encountered.
|
||||||
|
*/
|
||||||
|
sealed class RustLayerException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
|
||||||
|
class BalanceException(cause: Throwable) : RustLayerException("Error while requesting the current balance over " +
|
||||||
|
"JNI. This might mean that the database has been corrupted and needs to be rebuilt. Verify that " +
|
||||||
|
"blocks are not missing or have not been scanned out of order.", cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class RepositoryException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
|
||||||
|
object FalseStart: RepositoryException( "The channel is closed. Note that once a repository has stopped it " +
|
||||||
|
"cannot be restarted. Verify that the repository is not being restarted.")
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SynchronizerException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
|
||||||
|
object FalseStart: SynchronizerException("Once a synchronizer has stopped it cannotbe restarted. Instead, a new " +
|
||||||
|
"instance should be created.")
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class CompactBlockProcessorException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
|
||||||
|
class DataDbMissing(path: String): CompactBlockProcessorException("No data db file found at path $path. Verify " +
|
||||||
|
"that the data DB has been initialized via `converter.initDataDb(path)`")
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class CompactBlockStreamException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
|
||||||
|
object ConnectionClosed: CompactBlockStreamException("Cannot start stream when connection is closed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class WalletException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
|
||||||
|
object MissingParamsException : WalletException("Cannot send funds due to missing spend or output params and " +
|
||||||
|
"attempting to download them failed.")
|
||||||
|
class FetchParamsException(message: String) : WalletException("Failed to fetch params due to: $message")
|
||||||
|
}
|
|
@ -1,5 +1,8 @@
|
||||||
package cash.z.wallet.sdk.jni
|
package cash.z.wallet.sdk.jni
|
||||||
|
|
||||||
|
import cash.z.wallet.sdk.annotation.OpenForTesting
|
||||||
|
|
||||||
|
@OpenForTesting
|
||||||
class JniConverter {
|
class JniConverter {
|
||||||
|
|
||||||
external fun initDataDb(dbData: String): Boolean
|
external fun initDataDb(dbData: String): Boolean
|
||||||
|
|
|
@ -0,0 +1,142 @@
|
||||||
|
package cash.z.wallet.sdk.secure
|
||||||
|
|
||||||
|
import cash.z.wallet.sdk.data.SilentTwig
|
||||||
|
import cash.z.wallet.sdk.data.Twig
|
||||||
|
import cash.z.wallet.sdk.data.twigTask
|
||||||
|
import cash.z.wallet.sdk.exception.WalletException
|
||||||
|
import cash.z.wallet.sdk.jni.JniConverter
|
||||||
|
import com.squareup.okhttp.OkHttpClient
|
||||||
|
import com.squareup.okhttp.Request
|
||||||
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okio.Okio
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.properties.ReadOnlyProperty
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for the converter. This class basically represents all the Rust-wallet capabilities and the supporting data
|
||||||
|
* required to exercise those abilities.
|
||||||
|
*/
|
||||||
|
class Wallet(
|
||||||
|
private val converter: JniConverter,
|
||||||
|
private val dbDataPath: String,
|
||||||
|
private val paramDestinationDir: String,
|
||||||
|
/** indexes of accounts ids. In the reference wallet, we only work with account 0 */
|
||||||
|
private val accountIds: Array<Int> = arrayOf(0),
|
||||||
|
seedProvider: ReadOnlyProperty<Any?, ByteArray>,
|
||||||
|
logger: Twig = SilentTwig()
|
||||||
|
) : Twig by logger {
|
||||||
|
val seed by seedProvider
|
||||||
|
|
||||||
|
init {
|
||||||
|
// initialize data db for this wallet and its accounts
|
||||||
|
// initialize extended viewing keys for this wallet's seed and store them in the dataDb
|
||||||
|
// initialize spending keys
|
||||||
|
|
||||||
|
// call converter.initializeForSeed(seed, n) where n is the number of accounts
|
||||||
|
// get back an array of spending keys for each account. store them super securely
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBalance(accountId: Int = accountIds[0]) {
|
||||||
|
// TODO: modify request to factor in account Ids
|
||||||
|
converter.getBalance(dbDataPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: modify request to factor in account Ids
|
||||||
|
// TODO: once initializeForSeed exists then we won't need to hang onto it and use it here
|
||||||
|
suspend fun sendToAddress(value: Long, toAddress: String, fromAccountId: Int = accountIds[0]): Long =
|
||||||
|
withContext(IO) {
|
||||||
|
var result = -1L
|
||||||
|
twigTask("sending $value zatoshi to ${toAddress.masked()}") {
|
||||||
|
result = runCatching {
|
||||||
|
ensureParams(paramDestinationDir)
|
||||||
|
twig("params exist at $paramDestinationDir! attempting to send...")
|
||||||
|
converter.sendToAddress(
|
||||||
|
dbDataPath,
|
||||||
|
seed,
|
||||||
|
toAddress,
|
||||||
|
value,
|
||||||
|
// using names here so it's easier to avoid transposing them, if the function signature changes
|
||||||
|
spendParams = SPEND_PARAM_FILE_NAME.toPath(),
|
||||||
|
outputParams = OUTPUT_PARAM_FILE_NAME.toPath()
|
||||||
|
)
|
||||||
|
}.getOrDefault(result)
|
||||||
|
}
|
||||||
|
twig("result of sendToAddress: $result")
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchParams(destinationDir: String) = withContext(IO) {
|
||||||
|
val client = createHttpClient()
|
||||||
|
var failureMessage = ""
|
||||||
|
arrayOf(SPEND_PARAM_FILE_NAME, OUTPUT_PARAM_FILE_NAME).forEach { paramFileName ->
|
||||||
|
val url = "$CLOUD_PARAM_DIR_URL/$paramFileName"
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
twig("fetch succeeded")
|
||||||
|
val file = File(destinationDir, paramFileName)
|
||||||
|
if(file.parentFile.exists()) {
|
||||||
|
twig("directory exists!")
|
||||||
|
} else {
|
||||||
|
twig("directory did not exist attempting to make it")
|
||||||
|
file.parentFile.mkdirs()
|
||||||
|
}
|
||||||
|
Okio.buffer(Okio.sink(file)).use {
|
||||||
|
twig("writing to $file")
|
||||||
|
it.writeAll(response.body().source())
|
||||||
|
}
|
||||||
|
twig("fetch succeeded, done writing $paramFileName")
|
||||||
|
} else {
|
||||||
|
failureMessage += "Error while fetching $paramFileName : $response\n"
|
||||||
|
twig(failureMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failureMessage.isNotEmpty()) throw WalletException.FetchParamsException(failureMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureParams(destinationDir: String) {
|
||||||
|
var hadError = false
|
||||||
|
arrayOf(SPEND_PARAM_FILE_NAME, OUTPUT_PARAM_FILE_NAME).forEach { paramFileName ->
|
||||||
|
if (!File(destinationDir, paramFileName).exists()) {
|
||||||
|
twig("ERROR: $paramFileName not found at location: $destinationDir")
|
||||||
|
hadError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hadError) {
|
||||||
|
try {
|
||||||
|
twigTask("attempting to download missing params") {
|
||||||
|
fetchParams(destinationDir)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
twig("failed to fetch params due to: $e")
|
||||||
|
throw WalletException.MissingParamsException
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
// Helpers
|
||||||
|
//
|
||||||
|
|
||||||
|
private fun createHttpClient(): OkHttpClient {
|
||||||
|
//TODO: add logging and timeouts
|
||||||
|
return OkHttpClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.masked(): String = if (startsWith("ztest")) "****${takeLast(4)}" else "***masked***"
|
||||||
|
private fun String.toPath(): String = "$paramDestinationDir/$this"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* The Url that is used by default in zcashd.
|
||||||
|
* We'll want to make this externally configurable, rather than baking it into the SDK but this will do for now,
|
||||||
|
* since we're using a cloudfront URL that already redirects.
|
||||||
|
*/
|
||||||
|
const val CLOUD_PARAM_DIR_URL = "https://z.cash/downloads/"
|
||||||
|
const val SPEND_PARAM_FILE_NAME = "sapling-spend.params"
|
||||||
|
const val OUTPUT_PARAM_FILE_NAME = "sapling-output.params"
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ package cash.z.wallet.sdk.vo
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Ignore
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "received_notes",
|
tableName = "received_notes",
|
||||||
|
@ -23,29 +24,29 @@ import androidx.room.ForeignKey
|
||||||
)
|
)
|
||||||
data class Note(
|
data class Note(
|
||||||
@ColumnInfo(name = "id_note")
|
@ColumnInfo(name = "id_note")
|
||||||
val id: Int,
|
val id: Int = 0,
|
||||||
|
|
||||||
@ColumnInfo(name = "tx")
|
@ColumnInfo(name = "tx")
|
||||||
val transaction: Int,
|
val transaction: Int = 0,
|
||||||
|
|
||||||
@ColumnInfo(name = "output_index")
|
@ColumnInfo(name = "output_index")
|
||||||
val outputIndex: Int,
|
val outputIndex: Int = 0,
|
||||||
|
|
||||||
val account: Int,
|
val account: Int = 0,
|
||||||
val value: Int,
|
val value: Int = 0,
|
||||||
val spent: Int?,
|
val spent: Int? = 0,
|
||||||
|
|
||||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
||||||
val diversifier: ByteArray,
|
val diversifier: ByteArray = byteArrayOf(),
|
||||||
|
|
||||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
||||||
val rcm: ByteArray,
|
val rcm: ByteArray = byteArrayOf(),
|
||||||
|
|
||||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
||||||
val nf: ByteArray,
|
val nf: ByteArray = byteArrayOf(),
|
||||||
|
|
||||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
||||||
val memo: ByteArray?
|
val memo: ByteArray? = byteArrayOf()
|
||||||
) {
|
) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
|
@ -77,4 +78,6 @@ data class Note(
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class NoteQuery(val txId: Int, val value: Int, val height: Int, val sent: Boolean, val time: Long)
|
|
@ -16,7 +16,7 @@ import org.jetbrains.annotations.NotNull
|
||||||
)
|
)
|
||||||
data class Transaction(
|
data class Transaction(
|
||||||
@ColumnInfo(name = "id_tx")
|
@ColumnInfo(name = "id_tx")
|
||||||
val id: Int,
|
val id: Long,
|
||||||
|
|
||||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB, name = "txid")
|
@ColumnInfo(typeAffinity = ColumnInfo.BLOB, name = "txid")
|
||||||
@NotNull
|
@NotNull
|
||||||
|
@ -38,7 +38,7 @@ data class Transaction(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
var result = id
|
var result = id.toInt()
|
||||||
result = 31 * result + transactionId.contentHashCode()
|
result = 31 * result + transactionId.contentHashCode()
|
||||||
result = 31 * result + block
|
result = 31 * result + block
|
||||||
result = 31 * result + (raw?.contentHashCode() ?: 0)
|
result = 31 * result + (raw?.contentHashCode() ?: 0)
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
package cash.z.wallet.sdk.annotation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used in conjunction with the kotlin-allopen plugin to make any class with this annotation open for extension.
|
||||||
|
* Typically, we apply this to classes that we want to mock in androidTests because unit tests don't have this problem,
|
||||||
|
* it's only an issue with JUnit4 Instrumentation tests. This annotation is only leveraged in debug builds.
|
||||||
|
*
|
||||||
|
* Note: the counterpart to this annotation in the debug buildType applies the OpenClass annotation but here we do not.
|
||||||
|
*/
|
||||||
|
@Target(AnnotationTarget.CLASS)
|
||||||
|
annotation class OpenForTesting
|
|
@ -6,6 +6,6 @@ import org.mockito.Mockito
|
||||||
* Use in place of `any()` to fix the issue with mockito `any` returning null (so you can't pass it to functions that
|
* Use in place of `any()` to fix the issue with mockito `any` returning null (so you can't pass it to functions that
|
||||||
* take a non-null param)
|
* take a non-null param)
|
||||||
*
|
*
|
||||||
* TODO: perhaps submit this to the mockito kotlin project
|
* TODO: perhaps submit this function to the mockito kotlin project because it allows the use of non-null 'any()'
|
||||||
*/
|
*/
|
||||||
internal fun <T> anyNotNull() = Mockito.any<T>() as T
|
internal fun <T> anyNotNull() = Mockito.any<T>() as T
|
|
@ -6,10 +6,10 @@ import io.grpc.ManagedChannelBuilder
|
||||||
import org.junit.jupiter.api.*
|
import org.junit.jupiter.api.*
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
import rpc.CompactTxStreamerGrpc
|
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc
|
||||||
import rpc.Service
|
import cash.z.wallet.sdk.rpc.Service
|
||||||
import rpc.Service.BlockID
|
import cash.z.wallet.sdk.rpc.Service.BlockID
|
||||||
import rpc.Service.BlockRange
|
import cash.z.wallet.sdk.rpc.Service.BlockRange
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class GlueTest {
|
class GlueTest {
|
||||||
|
|
|
@ -8,9 +8,9 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
import org.junit.jupiter.api.BeforeAll
|
import org.junit.jupiter.api.BeforeAll
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import rpc.CompactTxStreamerGrpc
|
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc
|
||||||
import rpc.Service
|
import cash.z.wallet.sdk.rpc.Service
|
||||||
import rpc.Service.*
|
import cash.z.wallet.sdk.rpc.Service.*
|
||||||
import rpc.WalletDataOuterClass
|
import rpc.WalletDataOuterClass
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
|
@ -2,31 +2,39 @@ package cash.z.wallet.sdk.data
|
||||||
|
|
||||||
import cash.z.wallet.anyNotNull
|
import cash.z.wallet.anyNotNull
|
||||||
import cash.z.wallet.sdk.ext.toBlockHeight
|
import cash.z.wallet.sdk.ext.toBlockHeight
|
||||||
|
import cash.z.wallet.sdk.rpc.CompactFormats
|
||||||
|
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc.CompactTxStreamerBlockingStub
|
||||||
|
import cash.z.wallet.sdk.rpc.Service
|
||||||
import com.nhaarman.mockitokotlin2.*
|
import com.nhaarman.mockitokotlin2.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.junit.jupiter.api.*
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
import org.mockito.ArgumentMatchers.any
|
import org.mockito.ArgumentMatchers.any
|
||||||
import org.mockito.Mock
|
import org.mockito.Mock
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
import org.mockito.junit.jupiter.MockitoExtension
|
||||||
import org.mockito.junit.jupiter.MockitoSettings
|
import org.mockito.junit.jupiter.MockitoSettings
|
||||||
import org.mockito.quality.Strictness
|
import org.mockito.quality.Strictness
|
||||||
import rpc.CompactFormats
|
|
||||||
import rpc.CompactTxStreamerGrpc.CompactTxStreamerBlockingStub
|
|
||||||
import rpc.Service
|
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
import org.junit.Rule
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension::class)
|
@ExtendWith(MockitoExtension::class)
|
||||||
@MockitoSettings(strictness = Strictness.LENIENT) // allows us to setup the blockingStub once, with everything, rather than using custom stubs for each test
|
@MockitoSettings(strictness = Strictness.LENIENT) // allows us to setup the blockingStub once, with everything, rather than using custom stubs for each test
|
||||||
class CompactBlockDownloaderTest {
|
class CompactBlockDownloaderTest {
|
||||||
|
|
||||||
lateinit var downloader: CompactBlockDownloader
|
lateinit var downloader: CompactBlockStream
|
||||||
lateinit var connection: CompactBlockDownloader.Connection
|
lateinit var connection: CompactBlockStream.Connection
|
||||||
val job = Job()
|
val job = Job()
|
||||||
val io = CoroutineScope(Dispatchers.IO + job)
|
val io = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
var grpcServerRule = GrpcServerRule()
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun setUp(@Mock blockingStub: CompactTxStreamerBlockingStub) {
|
fun setUp(@Mock blockingStub: CompactTxStreamerBlockingStub) {
|
||||||
whenever(blockingStub.getLatestBlock(any())).doAnswer {
|
whenever(blockingStub.getLatestBlock(any())).doAnswer {
|
||||||
|
@ -53,8 +61,9 @@ class CompactBlockDownloaderTest {
|
||||||
}
|
}
|
||||||
delayedIterator
|
delayedIterator
|
||||||
}
|
}
|
||||||
connection = spy(CompactBlockDownloader.Connection(blockingStub))
|
downloader = CompactBlockStream(grpcServerRule.channel, TroubleshootingTwig())
|
||||||
downloader = CompactBlockDownloader(connection)
|
connection = spy(downloader.connection)
|
||||||
|
whenever(connection.createStub(any())).thenReturn(blockingStub)
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
|
|
Loading…
Reference in New Issue