Merge pull request #45 from zcash/task/improve-reorgs-v2

Task/improve reorgs v2
This commit is contained in:
Kevin Gorham 2019-08-30 10:27:09 -04:00 committed by GitHub
commit ff3a2b3bc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
98 changed files with 4051 additions and 2029 deletions

View File

@ -6,12 +6,12 @@ buildscript {
]
ext.versions = [
'architectureComponents': [
'lifecycle': '2.1.0-alpha03',
'room': '2.1.0-alpha06'
'lifecycle': '2.2.0-alpha02',
'room': '2.1.0'
],
'grpc':'1.19.0',
'kotlin': '1.3.21',
'coroutines': '1.1.1',
'grpc':'1.21.0',
'kotlin': '1.3.41',
'coroutines': '1.3.0-M1',
'junitJupiter': '5.5.0-M1'
]
repositories {
@ -22,7 +22,7 @@ buildscript {
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.0-alpha09'
classpath 'com.android.tools.build:gradle:3.5.0-beta05'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
classpath "org.jetbrains.kotlin:kotlin-allopen:${versions.kotlin}"
classpath "org.jetbrains.dokka:dokka-gradle-plugin:0.9.18"
@ -46,9 +46,10 @@ apply plugin: 'com.github.ben-manes.versions'
apply plugin: 'com.github.dcendents.android-maven'
apply plugin: 'com.getkeepsafe.dexcount'
apply plugin: 'org.mozilla.rust-android-gradle.rust-android'
apply plugin: 'org.owasp.dependencycheck'
group = 'cash.z.android.wallet'
version = '1.7.4'
version = '1.9.1'
repositories {
google()
@ -63,7 +64,7 @@ android {
defaultConfig {
minSdkVersion buildConfig.minSdkVersion
targetSdkVersion buildConfig.targetSdkVersion
versionCode = 1_07_05_00 // last digits are alpha(0X) beta(1X) rc(2X) release(3X). Ex: 1_08_04_20 is a RC build
versionCode = 1_09_01_00 // last digits are alpha(0X) beta(1X) rc(2X) release(3X). Ex: 1_08_04_20 is a RC build
versionName = "$version-alpha"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
@ -75,12 +76,12 @@ android {
debug {
// for test builds, which exceed the dex limit because they pull in things like mockito and grpc-testing
multiDexEnabled true
minifyEnabled true
minifyEnabled false
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
release {
multiDexEnabled false
minifyEnabled true
minifyEnabled false
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
@ -182,12 +183,13 @@ cargo {
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.1.0-alpha03'
implementation 'androidx.appcompat:appcompat:1.1.0-rc01'
// Architecture Components: Lifecycle
implementation "androidx.lifecycle:lifecycle-runtime:${versions.architectureComponents.lifecycle}"
implementation "androidx.lifecycle:lifecycle-extensions:${versions.architectureComponents.lifecycle}"
kapt "androidx.lifecycle:lifecycle-compiler:${versions.architectureComponents.lifecycle}"
implementation "androidx.lifecycle:lifecycle-common-java8:${versions.architectureComponents.lifecycle}"
// Architecture Components: Room
implementation "androidx.room:room-runtime:${versions.architectureComponents.room}"
@ -201,6 +203,7 @@ dependencies {
// grpc-java
implementation "io.grpc:grpc-okhttp:${versions.grpc}"
implementation "io.grpc:grpc-android:${versions.grpc}"
implementation "io.grpc:grpc-protobuf-lite:${versions.grpc}"
implementation "io.grpc:grpc-stub:${versions.grpc}"
implementation 'javax.annotation:javax.annotation-api:1.3.2'
@ -214,7 +217,7 @@ dependencies {
// Tests
testImplementation 'androidx.multidex:multidex:2.0.1'
testImplementation "org.jetbrains.kotlin:kotlin-reflect:${versions.kotlin}"
testImplementation 'org.mockito:mockito-junit-jupiter:2.25.1'
testImplementation 'org.mockito:mockito-junit-jupiter:2.26.0'
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0'
testImplementation "org.junit.jupiter:junit-jupiter-api:${versions.junitJupiter}"
testImplementation "org.junit.jupiter:junit-jupiter-engine:${versions.junitJupiter}"
@ -227,11 +230,12 @@ dependencies {
// because "JUnit 5 uses Java 8-specific APIs that didn't exist on Android before the Oreo release."
androidTestImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0'
androidTestImplementation 'org.mockito:mockito-android:2.25.1'
androidTestImplementation "androidx.test:runner:1.1.2-alpha02"
androidTestImplementation "androidx.test:core:1.1.1-alpha02"
androidTestImplementation "androidx.test:runner:1.2.0"
androidTestImplementation "androidx.test:core:1.2.0"
androidTestImplementation "androidx.arch.core:core-testing:2.0.1"
androidTestImplementation 'androidx.test.ext:junit:1.1.1-alpha02'
androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test:runner:1.2.0'
}
preBuild.dependsOn includeDirBugFix
check.dependsOn dependencyCheckAggregate

View File

@ -19,3 +19,4 @@ android.useAndroidX=true
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.enableR8=false

View File

@ -1,6 +1,6 @@
#Tue Mar 12 10:04:58 EDT 2019
#Fri Jun 07 02:04:27 EDT 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.3.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip

14
samples/addressAndKeys/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx

1
samples/addressAndKeys/app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,37 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 28
defaultConfig {
applicationId "cash.z.wallet.sdk.sample.address"
minSdkVersion 21
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
missingDimensionStrategy "network", "zcashtestnet"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
matchingFallbacks = ['zcashtestnetRelease']
}
}
}
dependencies {
compile project(path: ':sdk')
// api project(path: ':sdk', configuration: 'default')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}"
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.core:core-ktx:1.0.2'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.coroutines}"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutines}"
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="cash.z.wallet.sdk.sample.address">
<application
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,14 @@
package cash.z.wallet.sdk.sample.address
import android.app.Application
class App : Application() {
override fun onCreate() {
instance = this
super.onCreate()
}
companion object {
lateinit var instance: App
}
}

View File

@ -0,0 +1,34 @@
package cash.z.wallet.sdk.sample.address
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.jni.RustBackendWelding
import cash.z.wallet.sdk.secure.Wallet
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
object Injection {
private val rustBackend: RustBackendWelding = RustBackend()
private const val dataDbName = "AddressSampleData.db"
fun provideWallet(
seedProvider: ReadOnlyProperty<Any?, ByteArray>,
spendingKeyProvider: ReadWriteProperty<Any?, String>
): Wallet {
// simulate new session for each call
App.instance.getDatabasePath(dataDbName).absoluteFile.delete()
return Wallet(
context = App.instance,
birthday = Wallet.loadBirthdayFromAssets(App.instance, 421720),
rustBackend = provideRustBackend(),
dataDbName = dataDbName,
seedProvider = seedProvider,
spendingKeyProvider = spendingKeyProvider
)
}
fun provideRustBackend(): RustBackendWelding {
return rustBackend
}
}

View File

@ -0,0 +1,65 @@
package cash.z.wallet.sdk.sample.address
import android.os.Bundle
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import cash.z.wallet.sdk.data.TroubleshootingTwig
import cash.z.wallet.sdk.data.Twig
import cash.z.wallet.sdk.ext.SampleSeedProvider
import cash.z.wallet.sdk.secure.Wallet
import kotlin.properties.Delegates
/**
* Sample app that shows how to access the address and spending key.
*/
class MainActivity : AppCompatActivity() {
private lateinit var wallet: Wallet
private lateinit var addressInfo: TextView
// Secure storage is out of scope for this example (wallet makers know how to securely store things)
// However, any class can implement the required interface for these dependencies. The expectation is that a wallet
// maker would wrap an existing class with something that implements the property interface to access data. These
// dependencies would then point to those wrappers.
private val mockSecureStorage = Delegates.notNull<String>()
private val mockSecureSeedProvider = SampleSeedProvider("testreferencealice")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Twig.plant(TroubleshootingTwig())
addressInfo = findViewById(R.id.text_address_info)
wallet = Injection.provideWallet(mockSecureSeedProvider, mockSecureStorage).also { it.initialize() }
}
override fun onResume() {
super.onResume()
addressInfo.text = createAddressInfo()
}
private fun createAddressInfo(): String {
val address = wallet.getAddress()
val key by mockSecureStorage
val info = """
seed:
${mockSecureSeedProvider.seedValue}
--------------------------------------
address:
$address
--------------------------------------
spendingKey:
$key
""".trimIndent()
return info
}
fun onTestThings(view: View) {
// This is a good place to insert some test behavior to try out the SDK in response to a click
// it may help to add objects to the Injection.kt file
Toast.makeText(this, "Test SDK behavior", Toast.LENGTH_SHORT).show()
}
}

View File

@ -0,0 +1,26 @@
package cash.z.wallet.sdk.sample.address
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlin.coroutines.CoroutineContext
open class ScopedActivity : AppCompatActivity(), CoroutineScope {
private lateinit var job: Job
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
override fun onCreate(savedInstanceState: Bundle?) {
job = Job()
super.onCreate(savedInstanceState)
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
}

View File

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0"/>
<item
android:color="#00000000"
android:offset="1.0"/>
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1"/>
</vector>

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path android:fillColor="#008577"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".MainActivity">
<TextView
android:id="@+id/text_address_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.2" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="60dp"
android:onClick="onTestThings"
android:text="Test Something"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#008577</color>
<color name="colorPrimaryDark">#00574B</color>
<color name="colorAccent">#D81B60</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Zcash Address</string>
</resources>

View File

@ -0,0 +1,11 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

View File

@ -0,0 +1,26 @@
buildscript {
ext.versions = [
'kotlin': '1.3.31',
'coroutines': '1.3.0-M1'
]
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.0-beta04'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
}
}
allprojects {
repositories {
google()
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,21 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Sat Jun 08 14:45:29 EDT 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip

172
samples/addressAndKeys/gradlew vendored Executable file
View File

@ -0,0 +1,172 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"

84
samples/addressAndKeys/gradlew.bat vendored Normal file
View File

@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,3 @@
include ':app', ":sdk"
project(":sdk").projectDir = file("../../../zcash-android-wallet-sdk")
rootProject.name='Zcash Address'

View File

@ -28,12 +28,12 @@ dependencies {
implementation project(path: ':sdk', configuration: 'default')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}"
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.core:core-ktx:1.0.1'
implementation 'androidx.core:core-ktx:1.0.2'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.coroutines}"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutines}"
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
compile project(path: ':sdk')
}

View File

@ -1,33 +1,46 @@
package cash.z.wallet.sdk.sample.memo
import android.content.Context
import cash.z.wallet.sdk.block.CompactBlockDbStore
import cash.z.wallet.sdk.block.CompactBlockDownloader
import cash.z.wallet.sdk.block.CompactBlockProcessor
import cash.z.wallet.sdk.block.ProcessorConfig
import cash.z.wallet.sdk.data.*
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.secure.Wallet
import cash.z.wallet.sdk.service.LightWalletGrpcService
object Injection {
private const val host: String = "lightwalletd.z.cash"
private const val port: Int = 9067
private const val cacheDbName = "memos-cache.db"
private const val dataDbName = "memos-data.db"
private val rustBackend = RustBackend()
fun provideSynchronizer(appContext: Context): Synchronizer {
val dataDbName = CompactBlockProcessor.DEFAULT_DATA_DB_NAME
val repository = PollingTransactionRepository(appContext, dataDbName, 5000L)
val downloader = CompactBlockStream(host, port)
val config = ProcessorConfig(cacheDbName.toDbPath(appContext), dataDbName.toDbPath(appContext), downloadBatchSize = 1_000) // settings
val service = LightWalletGrpcService(appContext, host, port) // connects to lightwalletd
val blockStore = CompactBlockDbStore(appContext, cacheDbName) // enables compact block storage in cache
val downloader = CompactBlockDownloader(service, blockStore) // downloads blocks an puts them in storage
val repository = PollingTransactionRepository(appContext, dataDbName, rustBackend) // provides access to txs
val processor = CompactBlockProcessor(config, downloader, repository, rustBackend) // decrypts compact blocks
// wrapper for rustbackend
val wallet = Wallet(
context = appContext,
rustBackend = RustBackend(),
dataDbPath = appContext.getDatabasePath(dataDbName).absolutePath,
paramDestinationDir = "${appContext.cacheDir.absolutePath}/params",
seedProvider = SampleSeedProvider("testreferencealice"),
birthday = Wallet.loadBirthdayFromAssets(appContext, 421720),
rustBackend = rustBackend,
dataDbName = dataDbName,
seedProvider = SampleSeedProvider("testreferencecarol"),
spendingKeyProvider = SimpleProvider("dummyValue")
)
return SdkSynchronizer(
downloader = downloader,
processor = CompactBlockProcessor(appContext),
repository = repository,
activeTransactionManager = ActiveTransactionManager(repository, downloader.connection, wallet),
wallet = wallet
)
val activeTransactionManager = ActiveTransactionManager(repository, service, wallet) // monitors active txs
// ties everything together
return SdkSynchronizer(processor, repository, activeTransactionManager, wallet)
}
private fun String.toDbPath(context: Context): String {
return context.getDatabasePath(this).absolutePath
}
}

View File

@ -1,22 +1,35 @@
package cash.z.wallet.sdk.sample.memo
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.core.content.getSystemService
import cash.z.wallet.sdk.data.*
import cash.z.wallet.sdk.ext.convertZatoshiToZec
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.launch
import kotlin.properties.Delegates
import kotlin.properties.Delegates.observable
import kotlin.reflect.KProperty
class MainActivity : ScopedActivity() {
private lateinit var synchronizer: Synchronizer
private var progressJob: Job? = null
private var balanceJob: Job? = null
private var activeTransaction: TransactionInfo = TransactionInfo()
private var loaded: Boolean by observable(false) {_, old: Boolean, new: Boolean ->
if (!old && new) {
launch {
onBalance(synchronizer.getBalance())
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -33,12 +46,13 @@ class MainActivity : ScopedActivity() {
override fun onResume() {
super.onResume()
progressJob = launchProgressMonitor(synchronizer.progress())
balanceJob = launchBalanceMonitor(synchronizer.balances())
}
override fun onPause() {
super.onPause()
progressJob?.cancel()
progressJob = null
progressJob?.cancel().also { progressJob = null }
balanceJob?.cancel().also { balanceJob = null }
}
override fun onDestroy() {
@ -58,11 +72,30 @@ class MainActivity : ScopedActivity() {
}
}
private fun CoroutineScope.launchBalanceMonitor(channel: ReceiveChannel<Wallet.WalletBalance>) = launch {
for (i in channel) {
onBalance(i)
}
}
@SuppressLint("SetTextI18n")
private fun onBalance(balanceInfo: Wallet.WalletBalance) {
text_status.text = "Available Balance: ${balanceInfo.available.convertZatoshiToZec()} TAZ" +
"\nTotal Balance: ${balanceInfo.available.convertZatoshiToZec()} TAZ"
}
private fun onProgress(progress: Int) {
twig("Launching - onProgress $progress")
val isComplete = progress == 100
text_status.text = if(isComplete) "Balance: ${synchronizer.getAvailableBalance().convertZatoshiToZec(3)} TAZ" else "Synchronizing...\t$progress%"
button_send.isEnabled = isComplete
launch {
val isComplete = progress == 100
val status = when {
isComplete -> "".also { loaded = true }
progress > 90 -> "Synchronizing...finishing up"
else -> "Synchronizing...\t$progress%"
}
text_progress.text = status
button_send.isEnabled = isComplete
}
}
private fun onUpdate(transactions: Map<ActiveTransaction, TransactionState>) {

View File

@ -8,22 +8,32 @@
tools:context=".MainActivity">
<TextView
android:id="@+id/text_status"
android:id="@+id/text_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="48dp"
android:text="Initializing wallet..."
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintVertical_bias="0.2"/>
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.2"
tools:text="Initializing wallet..." />
<TextView
android:id="@+id/text_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_progress"
tools:text="Available balance: 5 TAZ\nTotal balance: 10 TAZ" />
<EditText
android:id="@+id/input_memo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginEnd="64dp"
android:layout_marginStart="64dp"
android:hint="Enter memo (after sync)"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
@ -34,10 +44,10 @@
android:id="@+id/button_send"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:onClick="onSendMemo"
android:text="Send"
android:enabled="false"
app:layout_constraintTop_toBottomOf="@id/input_memo"
app:layout_constraintEnd_toEndOf="@id/input_memo"/>
app:layout_constraintEnd_toEndOf="@id/input_memo"
app:layout_constraintTop_toBottomOf="@id/input_memo" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,9 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.versions = [
'kotlin': '1.3.21',
'coroutines': '1.1.1'
'kotlin': '1.3.31',
'coroutines': '1.3.0-M1'
]
repositories {
google()
@ -11,10 +9,8 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.0-alpha12'
classpath 'com.android.tools.build:gradle:3.5.0-beta04'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

View File

@ -1,6 +1,6 @@
#Tue Apr 23 23:04:09 EDT 2019
#Thu Jun 20 02:36:23 EDT 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.3.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip

View File

@ -4,10 +4,11 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.test.core.app.ApplicationProvider
import cash.z.wallet.sdk.dao.CompactBlockDao
import cash.z.wallet.sdk.entity.CompactBlock
import org.junit.*
import org.junit.Assert.*
import org.junit.AfterClass
import org.junit.Assert.assertNotNull
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
class CacheDbIntegrationTest {
@get:Rule

View File

@ -4,8 +4,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.test.core.app.ApplicationProvider
import cash.z.wallet.sdk.dao.BlockDao
import cash.z.wallet.sdk.dao.TransactionDao
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@ -39,7 +37,7 @@ class DerivedDbIntegrationTest {
@Test
fun testNoteQuery() {
val all = transactions.getAll()
val all = transactions.getReceivedTransactions()
assertEquals(3, all.size)
}
@ -47,7 +45,7 @@ class DerivedDbIntegrationTest {
fun testTransactionDaoPrepopulated() {
val tran = transactions.findById(1)
assertEquals(343987, tran?.block)
assertEquals(343987, tran?.minedHeight)
}
companion object {

View File

@ -5,21 +5,20 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.test.core.app.ApplicationProvider
import cash.z.wallet.sdk.dao.BlockDao
import cash.z.wallet.sdk.dao.CompactBlockDao
import cash.z.wallet.sdk.dao.TransactionDao
import cash.z.wallet.sdk.data.SampleSeedProvider
import cash.z.wallet.sdk.entity.CompactBlock
import cash.z.wallet.sdk.ext.toBlockHeight
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.entity.CompactBlock
import cash.z.wallet.sdk.jni.RustBackendWelding
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import org.junit.*
import org.junit.Assert.*
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc
import cash.z.wallet.sdk.rpc.Service
import cash.z.wallet.sdk.rpc.Service.*
import cash.z.wallet.sdk.rpc.Service.BlockID
import cash.z.wallet.sdk.rpc.Service.BlockRange
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import org.junit.AfterClass
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeUnit
class GlueIntegrationTest {

View File

@ -5,20 +5,21 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.test.core.app.ApplicationProvider
import cash.z.wallet.sdk.dao.BlockDao
import cash.z.wallet.sdk.dao.CompactBlockDao
import cash.z.wallet.sdk.dao.TransactionDao
import cash.z.wallet.sdk.entity.CompactBlock
import cash.z.wallet.sdk.ext.toBlockHeight
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.jni.RustBackendWelding
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import org.junit.*
import org.junit.Assert.*
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc
import cash.z.wallet.sdk.rpc.Service
import cash.z.wallet.sdk.rpc.Service.*
import cash.z.wallet.sdk.rpc.Service.BlockID
import cash.z.wallet.sdk.rpc.Service.BlockRange
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import org.junit.AfterClass
import org.junit.Assert.assertNotNull
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeUnit
class GlueSetupIntegrationTest {

View File

@ -1,10 +1,16 @@
package cash.z.wallet.sdk.db
import android.text.format.DateUtils
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.wallet.sdk.block.CompactBlockDbStore
import cash.z.wallet.sdk.block.CompactBlockDownloader
import cash.z.wallet.sdk.block.CompactBlockProcessor
import cash.z.wallet.sdk.block.ProcessorConfig
import cash.z.wallet.sdk.data.*
import cash.z.wallet.sdk.ext.SampleSeedProvider
import cash.z.wallet.sdk.ext.SampleSpendingKeyProvider
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.secure.Wallet
import cash.z.wallet.sdk.service.LightWalletGrpcService
import kotlinx.coroutines.runBlocking
import org.junit.AfterClass
import org.junit.Before
@ -21,13 +27,14 @@ class IntegrationTest {
private val cacheDdName = "IntegrationCache41.db"
private val context = InstrumentationRegistry.getInstrumentation().context
private lateinit var downloader: CompactBlockStream
private lateinit var downloader: CompactBlockDownloader
private lateinit var processor: CompactBlockProcessor
private lateinit var wallet: Wallet
@Before
fun setup() {
deleteDbs()
Twig.plant(TroubleshootingTwig())
}
private fun deleteDbs() {
@ -38,43 +45,48 @@ class IntegrationTest {
}
}
@Test(timeout = 1L * DateUtils.MINUTE_IN_MILLIS/10)
@Test(timeout = 120_000L)
fun testSync() = runBlocking<Unit> {
val rustBackend = RustBackend()
rustBackend.initLogs()
val logger = TroubleshootingTwig()
val config = ProcessorConfig(
cacheDbPath = context.getDatabasePath(cacheDdName).absolutePath,
dataDbPath = context.getDatabasePath(dataDbName).absolutePath,
downloadBatchSize = 2000,
blockPollFrequencyMillis = 10_000L
)
downloader = CompactBlockStream("10.0.2.2", 9067, logger)
processor = CompactBlockProcessor(context, rustBackend, cacheDdName, dataDbName, logger = logger)
val lightwalletService = LightWalletGrpcService(context,"192.168.1.134")
val compactBlockStore = CompactBlockDbStore(context, config.cacheDbPath)
downloader = CompactBlockDownloader(lightwalletService, compactBlockStore)
processor = CompactBlockProcessor(config, downloader, repository, rustBackend)
repository = PollingTransactionRepository(context, dataDbName, 10_000L)
wallet = Wallet(
context,
rustBackend,
context.getDatabasePath(dataDbName).absolutePath,
context.cacheDir.absolutePath,
arrayOf(0),
SampleSeedProvider("dummyseed"),
SampleSpendingKeyProvider("dummyseed")
context = context,
rustBackend = rustBackend,
dataDbName = dataDbName,
seedProvider = SampleSeedProvider("dummyseed"),
spendingKeyProvider = SampleSpendingKeyProvider("dummyseed")
)
// repository.start(this)
synchronizer = SdkSynchronizer(
downloader,
processor,
repository,
ActiveTransactionManager(repository, downloader.connection, wallet, logger),
wallet,
1000
).start(this)
for(i in synchronizer.progress()) {
logger.twig("made progress: $i")
}
// synchronizer = StableSynchronizer(wallet, repository, , processor)
// processor,
// repository,
// ActiveTransactionManager(repository, lightwalletService, wallet),
// wallet,
// 1000
// ).start(this)
//
// for(i in synchronizer.progress()) {
// twig("made progress: $i")
// }
}
companion object {
private lateinit var synchronizer: Synchronizer
private lateinit var repository: TransactionRepository
private lateinit var repository: PollingTransactionRepository
@AfterClass
fun tearDown() {
repository.stop()

View File

@ -4,7 +4,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.test.core.app.ApplicationProvider
import cash.z.wallet.sdk.dao.TransactionDao
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.BeforeClass

View File

@ -0,0 +1,77 @@
package cash.z.wallet.sdk.db
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.wallet.sdk.data.TroubleshootingTwig
import cash.z.wallet.sdk.data.Twig
import cash.z.wallet.sdk.ext.SampleSeedProvider
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import okio.Okio
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import java.io.IOException
import kotlin.properties.Delegates
import kotlin.properties.ReadWriteProperty
@ExperimentalCoroutinesApi
class AddressGeneratorUtil {
private val dataDbName = "AddressUtilData.db"
private val context = InstrumentationRegistry.getInstrumentation().context
private val rustBackend = RustBackend()
private lateinit var wallet: Wallet
@Before
fun setup() {
Twig.plant(TroubleshootingTwig())
rustBackend.initLogs()
}
private fun deleteDb() {
context.getDatabasePath(dataDbName).absoluteFile.delete()
}
@Test
fun generateAddresses() = runBlocking {
readLines().collect { seed ->
val keyStore = initWallet(seed)
val address = wallet.getAddress()
val pk by keyStore
println("xrxrx2\t$seed\t$address\t$pk")
}
Thread.sleep(5000)
assertEquals("foo", "bar")
}
@Throws(IOException::class)
fun readLines() = flow<String> {
val seedFile = javaClass.getResourceAsStream("/utils/seeds.txt")
Okio.buffer(Okio.source(seedFile)).use { source ->
var line: String? = source.readUtf8Line()
while (line != null) {
emit(line)
line = source.readUtf8Line()
}
}
}
private fun initWallet(seed: String): ReadWriteProperty<Any?, String> {
deleteDb()
val spendingKeyProvider = Delegates.notNull<String>()
wallet = Wallet(
context = context,
rustBackend = rustBackend,
dataDbName = dataDbName,
seedProvider = SampleSeedProvider(seed),
spendingKeyProvider = spendingKeyProvider
)
wallet.initialize()
return spendingKeyProvider
}
}

View File

@ -0,0 +1,147 @@
package cash.z.wallet.sdk.db
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.wallet.sdk.block.CompactBlockDbStore
import cash.z.wallet.sdk.block.CompactBlockDownloader
import cash.z.wallet.sdk.data.TroubleshootingTwig
import cash.z.wallet.sdk.data.Twig
import cash.z.wallet.sdk.data.twig
import cash.z.wallet.sdk.ext.SampleSeedProvider
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.secure.Wallet
import cash.z.wallet.sdk.service.LightWalletGrpcService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import okio.Okio
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import java.io.IOException
import kotlin.properties.Delegates
/**
* A tool for checking transactions since the given birthday and printing balances. This was useful for the Zcon1 app to
* ensure that we loaded all the pokerchips correctly.
*/
@ExperimentalCoroutinesApi
class BalancePrinterUtil {
private val host = "34.65.230.46"
private val downloadBatchSize = 9_000
private val birthday = 523240
private val context = InstrumentationRegistry.getInstrumentation().context
private val cacheDbName = "BalanceUtilCache.db"
private val dataDbName = "BalanceUtilData.db"
private val cacheDbPath = context.getDatabasePath("BalanceUtilCache.db").absolutePath
private val dataDbPath = context.getDatabasePath("BalanceUtilData.db").absolutePath
private val rustBackend = RustBackend()
private val downloader = CompactBlockDownloader(
LightWalletGrpcService(context, host),
CompactBlockDbStore(context, cacheDbName)
)
@Before
fun setup() {
Twig.plant(TroubleshootingTwig())
rustBackend.initLogs()
cacheBlocks()
}
private fun cacheBlocks() = runBlocking {
twig("downloading compact blocks...")
val latestBlockHeight = downloader.getLatestBlockHeight()
val lastDownloaded = downloader.getLastDownloadedHeight()
val blockRange = (Math.max(birthday, lastDownloaded))..latestBlockHeight
downloadNewBlocks(blockRange)
val error = validateNewBlocks(blockRange)
twig("validation completed with result $error")
assertEquals(-1, error)
}
private fun deleteDb(dbName: String) {
context.getDatabasePath(dbName).absoluteFile.delete()
}
@Test
fun printBalances() = runBlocking {
readLines().collect { seed ->
deleteDb(dataDbName)
initWallet(seed)
twig("scanning blocks for seed <$seed>")
rustBackend.scanBlocks(cacheDbPath, dataDbPath)
twig("done scanning blocks for seed $seed")
val total = rustBackend.getBalance(dataDbPath, 0)
twig("found total: $total")
val available = rustBackend.getVerifiedBalance(dataDbPath, 0)
twig("found available: $available")
twig("xrxrx2\t$seed\t$total\t$available")
println("xrxrx2\t$seed\t$total\t$available")
}
Thread.sleep(5000)
assertEquals("foo", "bar")
}
@Throws(IOException::class)
fun readLines() = flow<String> {
val seedFile = javaClass.getResourceAsStream("/utils/seeds.txt")
Okio.buffer(Okio.source(seedFile)).use { source ->
var line: String? = source.readUtf8Line()
while (line != null) {
emit(line)
line = source.readUtf8Line()
}
}
}
private fun initWallet(seed: String): Wallet {
val spendingKeyProvider = Delegates.notNull<String>()
return Wallet(
context = context,
birthday = Wallet.loadBirthdayFromAssets(context, birthday),
rustBackend = rustBackend,
dataDbName = dataDbName,
seedProvider = SampleSeedProvider(seed),
spendingKeyProvider = spendingKeyProvider
).apply {
runCatching {
initialize()
}
}
}
private fun downloadNewBlocks(range: IntRange) = runBlocking {
Twig.sprout("downloading")
twig("downloading blocks in range $range")
var downloadedBlockHeight = range.start
val count = range.last - range.first + 1
val batches = (count / downloadBatchSize + (if (count.rem(downloadBatchSize) == 0) 0 else 1))
twig("found $count missing blocks, downloading in $batches batches of $downloadBatchSize...")
for (i in 1..batches) {
val end = Math.min(range.first + (i * downloadBatchSize), range.last + 1)
val batchRange = downloadedBlockHeight until end
twig("downloaded $batchRange (batch $i of $batches)") {
downloader.downloadBlockRange(batchRange)
}
downloadedBlockHeight = end
}
Twig.clip("downloading")
}
private fun validateNewBlocks(range: IntRange?): Int {
val dummyWallet = initWallet("dummySeed")
Twig.sprout("validating")
twig("validating blocks in range $range")
val result = rustBackend.validateCombinedChain(cacheDbPath, dataDbPath)
Twig.clip("validating")
return result
}
}

View File

@ -0,0 +1,3 @@
seed-1
seed-2
seed-3

View File

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="cash.z.wallet.sdk">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View File

@ -0,0 +1,6 @@
{
"height": 518000,
"hash": "000ba586d734c295f0bc034be229b1c96cb040f9d4929efdb5d2b187eeb238fb",
"time": 1560645743,
"tree": "01a4f5240a88a6eb4ffbda7961a1430506aad1a50ba011593f02c243d968feb0550010000140f91773b4ab669846e5bcb96f60e68256c49a27872a98e9d5ce50b30a0c434e0000018968663d6a7b444591de83f8a07223113f5de7e8203807adacc7677c3bcd4f420194c7ecac0ef6d702d475680ec32051fdf6368af0c459ab450009c001bcbf7a5300000001f0eead5192c3b3ab7208429877570676647e448210332c6da7e18660b142b80e01b98b14cab05247195b3b3be3dd8639bae99a0dd10bed1282ac25b62a134afd7200000000011f8322ef806eb2430dc4a7a41c1b344bea5be946efc7b4349c1c9edb14ff9d39"
}

View File

@ -0,0 +1,6 @@
{
"height": 523240,
"hash": "00000c33da2196f0ed1bda71043f671fc69a0212e01f892653e212ab358f6b79",
"time": 1561002603,
"tree": "01d3e02bc1c2d66762f370b329a3063067701ad66c44b40285686bc8ff25f5616f00100154bff87bd0bda3b70a6d7754eca261de15fee3cd9bc53073a232e07fc3261e27000001a54dcaccb4c5e578aef89f2a3b4e3c3d8a487e6e904c5da5916118d721948d07000000000118fa9c6fef4963049dc7002a13bb0021d5e950591e48c9e5f2cbd1199429b80401f0eead5192c3b3ab7208429877570676647e448210332c6da7e18660b142b80e01b98b14cab05247195b3b3be3dd8639bae99a0dd10bed1282ac25b62a134afd7200000000011f8322ef806eb2430dc4a7a41c1b344bea5be946efc7b4349c1c9edb14ff9d39"
}

View File

@ -0,0 +1,46 @@
package cash.z.wallet.sdk.block
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
import cash.z.wallet.sdk.db.CompactBlockDao
import cash.z.wallet.sdk.db.CompactBlockDb
import cash.z.wallet.sdk.entity.CompactBlock
import cash.z.wallet.sdk.ext.SAPLING_ACTIVATION_HEIGHT
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
class CompactBlockDbStore(
applicationContext: Context,
cacheDbName: String
) : CompactBlockStore {
private val cacheDao: CompactBlockDao
private val cacheDb: CompactBlockDb
init {
cacheDb = createCompactBlockCacheDb(applicationContext, cacheDbName)
cacheDao = cacheDb.complactBlockDao()
}
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()
}
override suspend fun getLatestHeight(): Int = withContext(IO) {
val lastBlock = Math.max(0, cacheDao.latestBlockHeight() - 1)
if (lastBlock < SAPLING_ACTIVATION_HEIGHT) -1 else lastBlock
}
override suspend fun write(result: List<CompactBlock>) = withContext(IO) {
cacheDao.insert(result)
}
override suspend fun rewindTo(height: Int) = withContext(IO) {
cacheDao.rewindTo(height)
}
}

View File

@ -0,0 +1,41 @@
package cash.z.wallet.sdk.block
import cash.z.wallet.sdk.data.twig
import cash.z.wallet.sdk.service.LightWalletService
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
/**
* Serves as a source of compact blocks received from the light wallet server. Once started, it will use the given
* lightwallet service to request all the appropriate blocks and compact block store to persist them. By delegating to
* 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 compactBlockStore responsible for persisting the compact blocks that are received
*/
open class CompactBlockDownloader(
val lightwalletService: LightWalletService,
val compactBlockStore: CompactBlockStore
) {
suspend fun downloadBlockRange(heightRange: IntRange) = withContext(IO) {
val result = lightwalletService.getBlockRange(heightRange)
compactBlockStore.write(result)
}
suspend fun rewindTo(height: Int) = withContext(IO) {
// TODO: cancel anything in flight
compactBlockStore.rewindTo(height)
}
suspend fun getLatestBlockHeight() = withContext(IO) {
lightwalletService.getLatestBlockHeight()
}
suspend fun getLastDownloadedHeight() = withContext(IO) {
compactBlockStore.getLatestHeight()
}
}

View File

@ -0,0 +1,230 @@
package cash.z.wallet.sdk.block
import androidx.annotation.VisibleForTesting
import cash.z.wallet.sdk.annotation.OpenForTesting
import cash.z.wallet.sdk.data.TransactionRepository
import cash.z.wallet.sdk.data.Twig
import cash.z.wallet.sdk.data.twig
import cash.z.wallet.sdk.exception.CompactBlockProcessorException
import cash.z.wallet.sdk.ext.*
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.jni.RustBackendWelding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
/**
* Responsible for processing the compact blocks that are received from the lightwallet server. This class encapsulates
* all the business logic required to validate and scan the blockchain and is therefore tightly coupled with
* librustzcash.
*/
@OpenForTesting
class CompactBlockProcessor(
internal val config: ProcessorConfig,
internal val downloader: CompactBlockDownloader,
private val repository: TransactionRepository,
private val rustBackend: RustBackendWelding = RustBackend()
) {
var onErrorListener: ((Throwable) -> Boolean)? = null
var isConnected: Boolean = false
var isSyncing: Boolean = false
var isScanning: Boolean = false
private val progressChannel = ConflatedBroadcastChannel(0)
private var isStopped = false
private val consecutiveChainErrors = AtomicInteger(0)
fun progress(): ReceiveChannel<Int> = progressChannel.openSubscription()
/**
* Download compact blocks, verify and scan them.
*/
suspend fun start() = withContext(IO) {
twig("processor starting")
validateConfig()
// using do/while makes it easier to execute exactly one loop which helps with testing this processor quickly
// (because you can start and then immediately set isStopped=true to always get precisely one loop)
do {
retryWithBackoff(::onConnectionError, maxDelayMillis = config.maxBackoffInterval) {
val result = processNewBlocks()
// immediately process again after failures in order to download new blocks right away
if (result < 0) {
isSyncing = false
isScanning = false
consecutiveChainErrors.set(0)
twig("Successfully processed new blocks. Sleeping for ${config.blockPollFrequencyMillis}ms")
delay(config.blockPollFrequencyMillis)
} else {
if(consecutiveChainErrors.get() >= config.retries) {
val errorMessage = "ERROR: unable to resolve reorg at height $result after ${consecutiveChainErrors.get()} correction attempts!"
fail(CompactBlockProcessorException.FailedReorgRepair(errorMessage))
} else {
handleChainError(result)
}
consecutiveChainErrors.getAndIncrement()
}
}
} while (isActive && !isStopped)
twig("processor complete")
stop()
}
/**
* Validate the config to expose a common pitfall.
*/
private fun validateConfig() {
if(!config.cacheDbPath.contains(File.separator))
throw CompactBlockProcessorException.FileInsteadOfPath(config.cacheDbPath)
if(!config.dataDbPath.contains(File.separator))
throw CompactBlockProcessorException.FileInsteadOfPath(config.dataDbPath)
}
fun stop() {
isStopped = true
}
fun fail(error: Throwable) {
stop()
twig("${error.message}")
throw error
}
/**
* Process new blocks returning false whenever an error was found.
*
* @return -1 when processing was successful and did not encounter errors during validation or scanning. Otherwise
* return the block height where an error was found.
*/
private suspend fun processNewBlocks(): Int = withContext(IO) {
twig("beginning to process new blocks...")
// define ranges
val latestBlockHeight = downloader.getLatestBlockHeight()
isConnected = true // no exception on downloader call
isSyncing = true
val lastDownloadedHeight = Math.max(getLastDownloadedHeight(), SAPLING_ACTIVATION_HEIGHT - 1)
val lastScannedHeight = getLastScannedHeight()
twig("latestBlockHeight: $latestBlockHeight\tlastDownloadedHeight: $lastDownloadedHeight" +
"\tlastScannedHeight: $lastScannedHeight")
// as long as the database has the sapling tree (like when it's initialized from a checkpoint) we can avoid
// downloading earlier blocks so take the larger of these two numbers
val rangeToDownload = (Math.max(lastDownloadedHeight, lastScannedHeight) + 1)..latestBlockHeight
val rangeToScan = (lastScannedHeight + 1)..latestBlockHeight
downloadNewBlocks(rangeToDownload)
val error = validateNewBlocks(rangeToScan)
if (error < 0) {
scanNewBlocks(rangeToScan)
-1 // TODO: in theory scan should not fail when validate succeeds but maybe consider returning the failed block height whenever scan does fail
} else {
error
}
}
@VisibleForTesting //allow mocks to verify how this is called, rather than the downloader, which is more complex
internal suspend fun downloadNewBlocks(range: IntRange) = withContext<Unit>(IO) {
if (range.isEmpty()) {
twig("no blocks to download")
} else {
Twig.sprout("downloading")
twig("downloading blocks in range $range")
var downloadedBlockHeight = range.start
val missingBlockCount = range.last - range.first + 1
val batches = (missingBlockCount / config.downloadBatchSize
+ (if (missingBlockCount.rem(config.downloadBatchSize) == 0) 0 else 1))
var progress: Int
twig("found $missingBlockCount missing blocks, downloading in $batches batches of ${config.downloadBatchSize}...")
for (i in 1..batches) {
retryUpTo(config.retries) {
val end = Math.min(range.first + (i * config.downloadBatchSize), range.last + 1)
val batchRange = downloadedBlockHeight..(end - 1)
twig("downloaded $batchRange (batch $i of $batches)") {
downloader.downloadBlockRange(batchRange)
}
progress = Math.round(i / batches.toFloat() * 100)
// only report during large downloads. TODO: allow for configuration of "large"
progressChannel.send(progress)
downloadedBlockHeight = end
}
}
Twig.clip("downloading")
}
progressChannel.send(100)
}
private fun validateNewBlocks(range: IntRange?): Int {
if (range?.isEmpty() != false) {
twig("no blocks to validate: $range")
return -1
}
Twig.sprout("validating")
twig("validating blocks in range $range")
val result = rustBackend.validateCombinedChain(config.cacheDbPath, config.dataDbPath)
Twig.clip("validating")
return result
}
private fun scanNewBlocks(range: IntRange?): Boolean {
if (range?.isEmpty() != false) {
twig("no blocks to scan")
return true
}
Twig.sprout("scanning")
twig("scanning blocks in range $range")
isScanning = true
val result = rustBackend.scanBlocks(config.cacheDbPath, config.dataDbPath)
isScanning = false
Twig.clip("scanning")
return result
}
private suspend fun handleChainError(errorHeight: Int) = withContext(IO) {
val lowerBound = determineLowerBound(errorHeight)
twig("handling chain error at $errorHeight by rewinding to block $lowerBound")
rustBackend.rewindToHeight(config.dataDbPath, lowerBound)
downloader.rewindTo(lowerBound)
}
private fun onConnectionError(throwable: Throwable): Boolean {
isConnected = false
isSyncing = false
isScanning = false
return onErrorListener?.invoke(throwable) ?: true
}
private fun determineLowerBound(errorHeight: Int): Int {
val offset = Math.min(MAX_REORG_SIZE, config.rewindDistance * (consecutiveChainErrors.get() + 1))
return Math.max(errorHeight - offset, SAPLING_ACTIVATION_HEIGHT)
}
suspend fun getLastDownloadedHeight() = withContext(IO) {
downloader.getLastDownloadedHeight()
}
suspend fun getLastScannedHeight() = withContext(IO) {
repository.lastScannedHeight()
}
}
/**
* @property cacheDbPath absolute file path of the DB where raw, unprocessed compact blocks are stored.
* @property dataDbPath absolute file path of the DB where all information derived from the cache DB is stored.
*/
data class ProcessorConfig(
val cacheDbPath: String = "",
val dataDbPath: String = "",
val downloadBatchSize: Int = DEFAULT_BATCH_SIZE,
val blockPollFrequencyMillis: Long = DEFAULT_POLL_INTERVAL,
val retries: Int = DEFAULT_RETRIES,
val maxBackoffInterval: Long = DEFAULT_MAX_BACKOFF_INTERVAL,
val rewindDistance: Int = DEFAULT_REWIND_DISTANCE
)

View File

@ -0,0 +1,26 @@
package cash.z.wallet.sdk.block
import cash.z.wallet.sdk.entity.CompactBlock
/**
* Interface for storing compact blocks.
*/
interface CompactBlockStore {
/**
* Gets the highest block that is currently stored.
*/
suspend fun getLatestHeight(): Int
/**
* Write the given blocks to this store, which may be anything from an in-memory cache to a DB.
*/
suspend fun write(result: List<CompactBlock>)
/**
* Remove every block above and including the given height.
*
* After this operation, the data store will look the same as one that has not yet stored the given block height.
* Meaning, if max height is 100 block and rewindTo(50) is called, then the highest block remaining will be 49.
*/
suspend fun rewindTo(height: Int)
}

View File

@ -1,13 +0,0 @@
package cash.z.wallet.sdk.dao
import androidx.room.*
import cash.z.wallet.sdk.entity.Block
@Dao
interface BlockDao {
@Query("SELECT COUNT(height) FROM blocks")
fun count(): Int
@Query("SELECT MAX(height) FROM blocks")
fun lastScannedHeight(): Int
}

View File

@ -1,16 +0,0 @@
package cash.z.wallet.sdk.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import cash.z.wallet.sdk.entity.CompactBlock
@Dao
interface CompactBlockDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(block: CompactBlock)
@Query("SELECT MAX(height) FROM compactblocks")
fun latestBlockHeight(): Int
}

View File

@ -1,55 +0,0 @@
package cash.z.wallet.sdk.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Query
import cash.z.wallet.sdk.entity.Transaction
@Dao
interface TransactionDao {
@Query("SELECT * FROM transactions WHERE id_tx = :id")
fun findById(id: Long): Transaction?
@Delete
fun delete(transaction: Transaction)
@Query("DELETE FROM transactions WHERE id_tx = :id")
fun deleteById(id: Long)
/**
* Query transactions, aggregating information on send/receive, sorted carefully so the newest data is at the top
* and the oldest transactions are at the bottom.
*/
@Query("""
SELECT transactions.id_tx AS txId,
transactions.block AS height,
transactions.raw IS NOT NULL AS isSend,
transactions.block IS NOT NULL AS isMined,
blocks.time AS timeInSeconds,
sent_notes.address AS address,
CASE
WHEN transactions.raw IS NOT NULL THEN sent_notes.value
ELSE received_notes.value
END AS value
FROM transactions
LEFT JOIN sent_notes
ON transactions.id_tx = sent_notes.tx
LEFT JOIN received_notes
ON transactions.id_tx = received_notes.tx
LEFT JOIN blocks
ON transactions.block = blocks.height
WHERE received_notes.is_change != 1 or transactions.raw IS NOT NULL
ORDER BY block IS NOT NUll, height DESC, time DESC, txId DESC
""")
fun getAll(): List<WalletTransaction>
}
data class WalletTransaction(
val txId: Long = 0L,
val value: Long = 0L,
val height: Int? = null,
val isSend: Boolean = false,
val timeInSeconds: Long = 0L,
val address: String? = null,
val isMined: Boolean = false
)

View File

@ -1,311 +0,0 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.ext.masked
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
import java.util.*
import kotlin.coroutines.CoroutineContext
import cash.z.wallet.sdk.data.TransactionState.*
import cash.z.wallet.sdk.rpc.CompactFormats
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/**
* Manages active send/receive transactions. These are transactions that have been initiated but not completed with
* sufficient confirmations. All other transactions are stored in a separate [TransactionRepository].
*/
class ActiveTransactionManager(
private val repository: TransactionRepository,
private val service: CompactBlockStream.Connection,
private val wallet: Wallet
) : CoroutineScope {
private val job = Job()
override val coroutineContext: CoroutineContext = Dispatchers.Main + job
private lateinit var sentTransactionMonitorJob: Job
// private lateinit var confirmationMonitorJob: Job
// mutableMapOf gives the same result but we're explicit about preserving insertion order, since we rely on that
private val activeTransactions = LinkedHashMap<ActiveTransaction, TransactionState>()
private val channel = ConflatedBroadcastChannel<Map<ActiveTransaction, TransactionState>>()
private val transactionSubscription = repository.allTransactions()
// private val latestHeightSubscription = service.latestHeights()
fun subscribe(): ReceiveChannel<Map<ActiveTransaction, TransactionState>> {
return channel.openSubscription()
}
fun start() {
twig("ActiveTransactionManager starting")
sentTransactionMonitorJob = launchSentTransactionMonitor()
// confirmationMonitorJob = launchConfirmationMonitor() <- monitoring received transactions is disabled, presently <- TODO: enable confirmation monitor
}
fun stop() {
twig("ActiveTransactionManager stopping")
channel.cancel()
job.cancel()
sentTransactionMonitorJob.cancel()
transactionSubscription.cancel()
// confirmationMonitorJob.cancel() <- TODO: enable confirmation monitor
}
//
// State API
//
fun create(zatoshi: Long, toAddress: String): ActiveSendTransaction {
return ActiveSendTransaction(value = zatoshi, toAddress = toAddress).let { setState(it, TransactionState.Creating); it }
}
fun failure(transaction: ActiveTransaction, reason: String) {
setState(transaction, TransactionState.Failure(activeTransactions[transaction], reason))
}
fun created(transaction: ActiveSendTransaction, transactionId: Long) {
transaction.transactionId.set(transactionId)
setState(transaction, TransactionState.Created(transactionId))
}
fun upload(transaction: ActiveSendTransaction) {
setState(transaction, TransactionState.SendingToNetwork)
}
/**
* Request a cancel for this transaction. Once a transaction has been submitted it cannot be cancelled.
*
* @param transaction the send transaction to cancel
*
* @return true when the transaction can be cancelled. False when it is already in flight to the network.
*/
fun cancel(transaction: ActiveSendTransaction): Boolean {
val currentState = activeTransactions[transaction]
return if (currentState != null && currentState.order < TransactionState.SendingToNetwork.order) {
setState(transaction, TransactionState.Cancelled)
true
} else {
false
}
}
fun awaitConfirmation(transaction: ActiveTransaction, confirmationCount: Int = 0) {
setState(transaction, TransactionState.AwaitingConfirmations(confirmationCount))
}
fun isCancelled(transaction: ActiveSendTransaction): Boolean {
return activeTransactions[transaction] == TransactionState.Cancelled
}
/**
* Sets the state for this transaction and sends an update to subscribers on the main thread. The given transaction
* will be added if it does not match any existing transactions. If the given transaction was previously cancelled,
* this method takes no action.
*
* @param transaction the transaction to update and manage
* @param state the state to set for the given transaction
*/
private fun setState(transaction: ActiveTransaction, state: TransactionState) {
if (transaction is ActiveSendTransaction && isCancelled(transaction)) {
twig("state change to $state ignored because this send transaction has been cancelled")
} else {
twig("state set to $state for active transaction $transaction on thread ${Thread.currentThread().name}")
activeTransactions[transaction] = state
launch {
channel.send(activeTransactions)
}
}
}
private fun CoroutineScope.launchSentTransactionMonitor() = launch {
withContext(Dispatchers.IO) {
while(isActive && !transactionSubscription.isClosedForReceive) {
twig("awaiting next modification to transactions...")
val transactions = transactionSubscription.receive()
updateSentTransactions(transactions)
}
}
}
//TODO: enable confirmation monitor
// private fun CoroutineScope.launchConfirmationMonitor() = launch {
// withContext(Dispatchers.IO) {
// for (block in blockSubscription) {
// updateConfirmations(block)
// }
// }
// }
/**
* Synchronize our internal list of transactions to match any modifications that have occurred in the database.
*
* @param transactions the latest transactions received from our subscription to the transaction repository. That
* channel only publishes transactions when they have changed in some way.
*/
private fun updateSentTransactions(transactions: List<WalletTransaction>) {
twig("transaction modification received! Updating active sent transactions based on new transaction list")
val sentTransactions = transactions.filter { it.isSend }
val activeSentTransactions =
activeTransactions.entries.filter { (it.key is ActiveSendTransaction) && it.value.isActive() }
if(sentTransactions.isEmpty() || activeSentTransactions.isEmpty()) {
twig("done updating because the new transaction list" +
" ${if(sentTransactions.isEmpty()) "did not have any" else "had"} transactions and the active" +
" sent transactions is ${if(activeSentTransactions.isEmpty()) "" else "not"} empty.")
return
}
/* for all our active send transactions, see if there is a match in the DB and if so, update the height accordingly */
activeSentTransactions.forEach { (transaction, _) ->
val tx = transaction as ActiveSendTransaction
val transactionId = tx.transactionId.get()
if (tx.height.get() < 0) {
twig("checking whether active transaction $transactionId has been mined")
val matchingDbTransaction = sentTransactions.find { it.txId == transactionId }
if (matchingDbTransaction?.height != null) {
twig("transaction $transactionId HAS BEEN MINED!!! updating the corresponding active transaction.")
tx.height.set(matchingDbTransaction.height)
twig("active transaction height updated to ${matchingDbTransaction.height} and state updated to AwaitingConfirmations(0)")
setState(transaction, AwaitingConfirmations(1))
} else {
twig("transaction $transactionId has still not been mined.")
}
}
}
}
// TODO: enable confirmation monitor
// private fun updateConfirmations(block: CompactFormats.CompactBlock) {
// twig("updating confirmations for all active transactions")
// val txsAwaitingConfirmation =
// activeTransactions.entries.filter { it.value is AwaitingConfirmations }
// for (tx in txsAwaitingConfirmation) {
//
// }
// }
//
// Active Transaction Management
//
suspend fun sendToAddress(zatoshi: Long, toAddress: String, memo: String = "", fromAccountId: Int = 0) = withContext(Dispatchers.IO) {
twig("creating send transaction for zatoshi value $zatoshi")
val activeSendTransaction = create(zatoshi, toAddress.masked())
val transactionId: Long = wallet.createRawSendTransaction(zatoshi, toAddress, memo, fromAccountId) // this call takes up to 20 seconds
// cancellation basically just prevents sending to the network but we cannot cancel after this moment
// well, technically we could still allow cancellation in the split second between this line of code and the upload request but lets not complicate things
if(isCancelled(activeSendTransaction)) {
twig("transaction $transactionId will not be submitted because it has been cancelled")
revertTransaction(transactionId)
return@withContext
}
if (transactionId < 0) {
failure(activeSendTransaction, "Failed to create, possibly due to insufficient funds or an invalid key")
return@withContext
}
val transactionRaw: ByteArray? = repository.findTransactionById(transactionId)?.raw
if (transactionRaw == null) {
failure(activeSendTransaction, "Failed to find the transaction that we just attempted to create in the dataDb")
return@withContext
}
created(activeSendTransaction, transactionId)
uploadRawTransaction(transactionId, activeSendTransaction, transactionRaw)
//TODO: synchronously await confirmations by checking periodically inside a while loop until confirmations = 10
}
private suspend fun uploadRawTransaction(
transactionId: Long,
activeSendTransaction: ActiveSendTransaction,
transactionRaw: ByteArray
) {
try {
twig("attempting to submit transaction $transactionId")
upload(activeSendTransaction)
val response = service.submitTransaction(transactionRaw)
if (response.errorCode < 0) {
twig("submit failed with error code: ${response.errorCode} and message ${response.errorMessage}")
failure(activeSendTransaction, "Send failed due to ${response.errorMessage}")
} else {
twig("successfully submitted. error code: ${response.errorCode}")
awaitConfirmation(activeSendTransaction)
}
} catch (t: Throwable) {
val logMessage = "submit failed due to $t."
twig(logMessage)
val revertMessage = revertTransaction(transactionId)
failure(activeSendTransaction, "$logMessage $revertMessage Failure caused by: ${t.message}")
}
}
private suspend fun revertTransaction(transactionId: Long): String = withContext(Dispatchers.IO) {
var revertMessage = "Failed to revert pending send id $transactionId in the dataDb."
try {
repository.deleteTransactionById(transactionId)
revertMessage = "The pending send with id $transactionId has been removed from the DB."
} catch (t: Throwable) {
}
revertMessage
}
}
data class ActiveSendTransaction(
/** height where the transaction was minded. -1 when unmined */
val height: AtomicInteger = AtomicInteger(-1),
/** Transaction row that corresponds with this send. -1 when the transaction hasn't been created yet. */
val transactionId: AtomicLong = AtomicLong(-1L),
override val value: Long = 0,
override val internalId: UUID = UUID.randomUUID(),
val toAddress: String = ""
) : ActiveTransaction
data class ActiveReceiveTransaction(
val height: Int = -1,
override val value: Long = 0,
override val internalId: UUID = UUID.randomUUID()
) : ActiveTransaction
interface ActiveTransaction {
val value: Long
/** only used by this class for purposes of managing unique transactions */
val internalId: UUID
}
sealed class TransactionState(val order: Int) {
val timestamp: Long = System.currentTimeMillis()
object Creating : TransactionState(0)
/** @param txId row in the database where the raw transaction has been stored, temporarily, by the rust lib */
class Created(val txId: Long) : TransactionState(10)
object SendingToNetwork : TransactionState(20)
class AwaitingConfirmations(val confirmationCount: Int) : TransactionState(30) {
override fun toString(): String {
return "${super.toString()}($confirmationCount)"
}
}
object Cancelled : TransactionState(-1)
/** @param failedStep the state of this transaction at the time, prior to failure */
class Failure(val failedStep: TransactionState?, val reason: String = "") : TransactionState(-2) {
override fun toString(): String {
return "${super.toString()}($failedStep) : $reason"
}
}
fun isActive(): Boolean {
return order > 0
}
override fun toString(): String {
return javaClass.simpleName
}
}

View File

@ -1,122 +0,0 @@
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.RustBackend
import cash.z.wallet.sdk.jni.RustBackendWelding
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
/**
* 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.
*/
class CompactBlockProcessor(
applicationContext: Context,
val rustBackend: RustBackendWelding = RustBackend(),
cacheDbName: String = DEFAULT_CACHE_DB_NAME,
dataDbName: String = DEFAULT_DATA_DB_NAME
) {
internal val cacheDao: CompactBlockDao
private val cacheDb: CompactBlockDb
private val cacheDbPath: String
private val dataDbPath: String
val dataDbExists get() = File(dataDbPath).exists()
val cachDbExists get() = File(cacheDbPath).exists()
init {
cacheDb = createCompactBlockCacheDb(applicationContext, cacheDbName)
cacheDao = cacheDb.complactBlockDao()
cacheDbPath = applicationContext.getDatabasePath(cacheDbName).absolutePath
dataDbPath = applicationContext.getDatabasePath(dataDbName).absolutePath
}
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}")
cacheDao.insert(cash.z.wallet.sdk.entity.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) {
Twig.sprout("scan")
twigTask("scanning blocks") {
if (isActive) {
try {
rustBackend.scanBlocks(cacheDbPath, dataDbPath)
} catch (t: Throwable) {
twig("error while scanning blocks: $t")
}
}
}
Twig.clip("scan")
}
/**
* Returns the height of the last processed block or -1 if no blocks have been processed.
*/
suspend fun lastProcessedBlock(): Int = withContext(IO) {
val lastBlock = Math.max(0, cacheDao.latestBlockHeight() - 1)
if (lastBlock < SAPLING_ACTIVATION_HEIGHT) -1 else lastBlock
}
companion object {
const val DEFAULT_CACHE_DB_NAME = "DownloadedCompactBlocks.db"
const val DEFAULT_DATA_DB_NAME = "CompactBlockScanResults.db"
/** Default amount of time to synchronize before initiating the first scan. This allows time to download a few blocks. */
const val INITIAL_SCAN_DELAY = 3000L
/** Minimum amount of time between scans. The frequency with which we check whether the block height has changed and, if so, trigger a scan */
const val SCAN_FREQUENCY = 75_000L
// TODO: find a better home for this constant
const val SAPLING_ACTIVATION_HEIGHT = 280_000
}
}

View File

@ -1,214 +0,0 @@
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.ConflatedBroadcastChannel
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() {
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) : this(
ManagedChannelBuilder.forAddress(host, port).usePlaintext().build()
)
constructor(channel: Channel) : this() {
connection = Connection(channel)
}
fun start(
scope: CoroutineScope,
startingBlockHeight: Int = Int.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)
}
} catch (t: Throwable) {
twig("throwing $t")
throw CompactBlockStreamException.FalseStart(t)
}
}
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 latestBlockHeightChannel = ConflatedBroadcastChannel<Int>()
private val progressChannel = ConflatedBroadcastChannel<Int>()
fun createStub(timeoutMillis: Long = 60_000L): CompactTxStreamerBlockingStub {
return CompactTxStreamerGrpc.newBlockingStub(channel).withDeadlineAfter(timeoutMillis, TimeUnit.MILLISECONDS)
}
fun subscribe() = compactBlockChannel.openSubscription()
fun progress() = progressChannel.openSubscription()
fun latestHeights() = latestBlockHeightChannel.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: Int, batchSize: Int = DEFAULT_BATCH_SIZE): Int {
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) == 0) 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 of $batches\n")
}
}
// progressChannel.cancel()
} else {
twig("no missing blocks to download!")
}
return downloadedBlockHeight
}
suspend fun getLatestBlockHeight(): Int = withContext(IO) {
createStub().getLatestBlock(Service.ChainSpec.newBuilder().build()).height.toInt()
}
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: Int = Int.MAX_VALUE) = withContext(IO) {
twig("streamBlocks started at $startingBlockHeight with interval $pollFrequencyMillis")
progressChannel.send(100) // anytime we make it to this method, we're done catching up
// 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: IntRange): 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)
latestBlockHeightChannel.send(nextBlock.height.toInt())
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
}
}
}

View File

@ -1,17 +1,6 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.CoroutineContext
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.random.nextLong
import kotlinx.coroutines.CoroutineScope
/**
* Utility for building UIs. It does the best it can to mock the SDKSynchronizer so that it can be dropped into any
@ -24,336 +13,326 @@ import kotlin.random.nextLong
* will send regular updates such that it reaches 100 in this amount of time.
* @param activeTransactionUpdateFrequency the amount of time in milliseconds between updates to an active
* transaction's state. Active transactions move through their lifecycle and increment their state at this rate.
* @param isFirstRun whether this Mock should return `true` for isFirstRun. Defaults to a random boolean.
* @param isStale whether this Mock should return `true` for isStale. When null, this will follow the default behavior
* of returning true about 10% of the time.
* @param onSynchronizerErrorListener presently ignored because there are not yet any errors in mock.
*/
open class MockSynchronizer(
abstract class MockSynchronizer(
private val transactionInterval: Long = 30_000L,
private val initialLoadDuration: Long = 5_000L,
private val activeTransactionUpdateFrequency: Long = 3_000L,
private val isFirstRun: Boolean = Random.nextBoolean(),
private var isStale: Boolean? = null,
override var onSynchronizerErrorListener: ((Throwable?) -> Boolean)? = null // presently ignored (there are no errors in mock yet)
private var isStale: Boolean? = null
) : Synchronizer, CoroutineScope {
private val mockAddress = "ztestsaplingmock0000this0is0a0mock0address0do0not0send0funds0to0this0address0ok0thanks00"
//TODO: things have changed a lot and this class needs to be redone, from the ground up!
private val job = Job()
/**
* Coroutine context used for the CoroutineScope implementation, used to mock asynchronous behaviors.
*/
override val coroutineContext: CoroutineContext
get() = Dispatchers.IO + job
/* only accessed through mutual exclusion */
private val transactions = mutableListOf<WalletTransaction>()
private val activeTransactions = mutableMapOf<ActiveTransaction, TransactionState>()
private val transactionMutex = Mutex()
private val activeTransactionMutex = Mutex()
private val forge = Forge()
private val balanceChannel = ConflatedBroadcastChannel<Wallet.WalletBalance>()
private val activeTransactionsChannel = ConflatedBroadcastChannel<Map<ActiveTransaction, TransactionState>>(mutableMapOf())
private val transactionsChannel = ConflatedBroadcastChannel<List<WalletTransaction>>(listOf())
private val progressChannel = ConflatedBroadcastChannel<Int>()
/**
* Starts this mock Synchronizer.
*/
override fun start(parentScope: CoroutineScope): Synchronizer {
Twig.sprout("mock")
twig("synchronizer starting")
forge.start(parentScope)
return this
}
/**
* Stops this mock Synchronizer by cancelling its primary job.
*/
override fun stop() {
twig("synchronizer stopping!")
Twig.clip("mock")
job.cancel()
}
override fun activeTransactions() = activeTransactionsChannel.openSubscription()
override fun allTransactions() = transactionsChannel.openSubscription()
override fun balances() = balanceChannel.openSubscription()
override fun progress() = progressChannel.openSubscription()
/**
* Returns true roughly 10% of the time and then resets to false after some delay.
*/
override suspend fun isStale(): Boolean {
val result = isStale ?: (Random.nextInt(100) < 10)
twig("checking isStale: $result")
if(isStale == true) launch { delay(20_000L); isStale = false }
return result
}
/**
* Returns [isFirstRun] as provided during initialization of this MockSynchronizer.
*/
override suspend fun isFirstRun(): Boolean {
twig("checking isFirstRun: $isFirstRun")
return isFirstRun
}
/**
* Returns the [mockAddress]. This address is not usable.
*/
override fun getAddress(accountId: Int): String = mockAddress.also { twig("returning mock address $mockAddress") }
/**
* Returns the available balance by adding up all the transactions and subtracting the miner's fee.
*/
override fun getAvailableBalance(accountId: Int): Long {
if (transactions.size != 0) {
return transactions.fold(0L) { acc, tx ->
if (tx.isSend && tx.isMined) acc - tx.value else acc + tx.value
} - 10_000L // miner's fee
}
return 0L
}
/**
* Uses the [forge] to fabricate a transaction and then walk it through the transaction lifecycle in a useful way.
* This method will validate the zatoshi amount and toAddress a bit to help with UI validation.
*
* @param zatoshi the amount to send. A transaction will be created matching this amount.
* @param toAddress the address to use. An active transaction will be created matching this address.
* @param memo the memo to use. This field is ignored.
* @param fromAccountId the account. This field is ignored.
*/
override suspend fun sendToAddress(zatoshi: Long, toAddress: String, memo: String, fromAccountId: Int) =
withContext<Unit>(Dispatchers.IO) {
Twig.sprout("send")
val walletTransaction = forge.createSendTransaction(zatoshi)
val activeTransaction = forge.createActiveSendTransaction(walletTransaction, toAddress)
val isInvalidForTestnet = toAddress.length != 88 && toAddress.startsWith("ztest")
val isInvalidForMainnet = toAddress.length != 78 && toAddress.startsWith("zs")
val state = when {
zatoshi < 0 -> TransactionState.Failure(TransactionState.Creating, "amount cannot be negative")
!toAddress.startsWith("z") -> TransactionState.Failure(
TransactionState.Creating,
"address must start with z"
)
isInvalidForTestnet -> TransactionState.Failure(TransactionState.Creating, "invalid testnet address")
isInvalidForMainnet -> TransactionState.Failure(TransactionState.Creating, "invalid mainnet address")
else -> TransactionState.Creating
}
twig("after input validation, state is being set to ${state::class.simpleName}")
setState(activeTransaction, state)
twig("active tx size is ${activeTransactions.size}")
// next, transition it through the states, if it got created
if (state !is TransactionState.Creating) {
twig("failed to create transaction")
return@withContext
} else {
// first, add the transaction
twig("adding transaction")
transactionMutex.withLock {
transactions.add(walletTransaction)
}
// then update the active transaction through the creation and submission steps
listOf(TransactionState.Created(walletTransaction.txId), TransactionState.SendingToNetwork)
.forEach { newState ->
if (!job.isActive) return@withContext
delay(activeTransactionUpdateFrequency)
setState(activeTransaction, newState)
}
// then set the wallet transaction's height (to simulate it being mined)
val minedHeight = forge.latestHeight.getAndIncrement()
transactionMutex.withLock {
transactions.remove(walletTransaction)
transactions.add(walletTransaction.copy(height = minedHeight, isMined = true))
}
// simply transition it through the states
List(11) { TransactionState.AwaitingConfirmations(it) }
.forEach { newState ->
if (!job.isActive) return@withContext
delay(activeTransactionUpdateFrequency)
activeTransaction.height.set(minedHeight + newState.confirmationCount)
setState(activeTransaction, newState)
}
}
Twig.clip("send")
}
/**
* Helper method to update the state of the given active transaction.
*
* @param activeTransaction the transaction to update.
* @param state the new state to set.
*/
private suspend fun setState(activeTransaction: ActiveTransaction, state: TransactionState) {
var copyMap = mutableMapOf<ActiveTransaction, TransactionState>()
activeTransactionMutex.withLock {
val currentState = activeTransactions[activeTransaction]
if ((currentState?.order ?: 0) < 0) {
twig("ignoring state ${state::class.simpleName} " +
"because the current state is ${currentState!!::class.simpleName}")
return
}
activeTransactions[activeTransaction] = state
var count = if (state is TransactionState.AwaitingConfirmations) "(${state.confirmationCount})" else ""
twig("state set to ${state::class.simpleName}$count on thread ${Thread.currentThread().name}")
}
copyMap = activeTransactions.toMutableMap()
twig("sending ${copyMap.size} active transactions")
launch {
activeTransactionsChannel.send(copyMap)
}
}
/**
* Sets the state of the given transaction to 'Cancelled'.
*/
override fun cancelSend(transaction: ActiveSendTransaction): Boolean {
launch {
twig("cancelling transaction $transaction")
setState(transaction, TransactionState.Cancelled)
}
return true
}
/**
* Utility for forging transactions in both senses of the word.
*/
private inner class Forge {
val transactionId = AtomicLong(Random.nextLong(1L..100_000L))
val latestHeight = AtomicInteger(Random.nextInt(280000..600000))
/**
* Fire up this forge to begin fabricating transactions.
*/
fun start(scope: CoroutineScope) {
scope.launchAddReceiveTransactions()
scope.launchUpdateTransactionsAndBalance()
scope.launchUpdateProgress()
}
/**
* Take the current list of transactions in the outer class (in a thread-safe way) and send updates to the
* transaction and balance channels on a regular interval, regardless of what data is present in the
* transactions collection.
*/
fun CoroutineScope.launchUpdateTransactionsAndBalance() = launch {
while (job.isActive) {
if (transactions.size != 0) {
var balance = 0L
transactionMutex.withLock {
// does not factor in confirmations
balance =
transactions.fold(0L) { acc, tx ->
if (tx.isSend && tx.isMined) acc - tx.value else acc + tx.value
}
}
balanceChannel.send(Wallet.WalletBalance(balance, balance - 10000 /* miner's fee */))
}
// other collaborators add to the list, periodically. This simulates, real-world, non-distinct updates.
delay(Random.nextLong(transactionInterval / 2))
var copyList = listOf<WalletTransaction>()
transactionMutex.withLock {
// shallow copy
copyList = transactions.map { it }
}
twig("sending ${copyList.size} transactions")
transactionsChannel.send(copyList)
}
}
/**
* Periodically create a transaction and add it to the running list of transactions in the outer class, knowing
* that this list of transactions will be periodically broadcast by the `launchUpdateTransactionsAndBalance`
* function.
*/
fun CoroutineScope.launchAddReceiveTransactions() = launch {
while (job.isActive) {
delay(transactionInterval)
transactionMutex.withLock {
twig("adding received transaction with random value")
transactions.add(
createReceiveTransaction()
.also { twig("adding received transaction with random value: ${it.value}") }
)
}
}
}
/**
* Fabricate a stream of progress events.
*/
fun CoroutineScope.launchUpdateProgress() = launch {
var progress = 0
while (job.isActive) {
delay(initialLoadDuration/100)
twig("sending progress of $progress")
progressChannel.send(progress++)
if(progress > 100) break
}
twig("progress channel complete!")
}
/**
* Fabricate a receive transaction.
*/
fun createReceiveTransaction(): WalletTransaction {
return WalletTransaction(
txId = transactionId.getAndIncrement(),
value = Random.nextLong(20_000L..1_000_000_000L),
height = latestHeight.getAndIncrement(),
isSend = false,
timeInSeconds = System.currentTimeMillis() / 1000,
isMined = true
)
}
/**
* Fabricate a send transaction.
*/
fun createSendTransaction(
amount: Long = Random.nextLong(20_000L..1_000_000_000L),
txId: Long = -1L
): WalletTransaction {
return WalletTransaction(
txId = if (txId == -1L) transactionId.getAndIncrement() else txId,
value = amount,
height = null,
isSend = true,
timeInSeconds = System.currentTimeMillis() / 1000,
isMined = false
)
}
/**
* Fabricate an active send transaction, based on the given wallet transaction instance.
*/
fun createActiveSendTransaction(walletTransaction: WalletTransaction, toAddress: String)
= createActiveSendTransaction(walletTransaction.value, toAddress, walletTransaction.txId)
/**
* Fabricate an active send transaction.
*/
fun createActiveSendTransaction(amount: Long, address: String, txId: Long = -1): ActiveSendTransaction {
return ActiveSendTransaction(
transactionId = AtomicLong(if (txId < 0) transactionId.getAndIncrement() else txId),
toAddress = address,
value = amount
)
}
}
//
// private val mockAddress = "ztestsaplingmock0000this0is0a0mock0address0do0not0send0funds0to0this0address0ok0thanks00"
//
// private val job = Job()
//
// /**
// * Coroutine context used for the CoroutineScope implementation, used to mock asynchronous behaviors.
// */
// override val coroutineContext: CoroutineContext
// get() = Dispatchers.IO + job
//
// /* only accessed through mutual exclusion */
// private val transactions = mutableListOf<ClearedTransaction>()
// private val activeTransactions = mutableMapOf<ActiveTransaction, TransactionState>()
//
// private val transactionMutex = Mutex()
// private val activeTransactionMutex = Mutex()
//
// private val forge = Forge()
//
// private val balanceChannel = ConflatedBroadcastChannel<Wallet.WalletBalance>()
// private val activeTransactionsChannel = ConflatedBroadcastChannel<Map<ActiveTransaction, TransactionState>>(mutableMapOf())
// private val transactionsChannel = ConflatedBroadcastChannel<List<ClearedTransaction>>(listOf())
// private val progressChannel = ConflatedBroadcastChannel<Int>()
//
// /**
// * Starts this mock Synchronizer.
// */
// override fun start(parentScope: CoroutineScope): Synchronizer {
// Twig.sprout("mock")
// twig("synchronizer starting")
// forge.start(parentScope)
// return this
// }
//
// /**
// * Stops this mock Synchronizer by cancelling its primary job.
// */
// override fun stop() {
// twig("synchronizer stopping!")
// Twig.clip("mock")
// job.cancel()
// }
//
// override fun activeTransactions() = activeTransactionsChannel.openSubscription()
// override fun allTransactions() = transactionsChannel.openSubscription()
// override fun balances() = balanceChannel.openSubscription()
// override fun progress() = progressChannel.openSubscription()
//
// /**
// * Returns true roughly 10% of the time and then resets to false after some delay.
// */
// override suspend fun isStale(): Boolean {
// val result = isStale ?: (Random.nextInt(100) < 10)
// twig("checking isStale: $result")
// if(isStale == true) launch { delay(20_000L); isStale = false }
// return result
// }
//
// /**
// * Returns the [mockAddress]. This address is not usable.
// */
// override fun getAddress(accountId: Int): String = mockAddress.also { twig("returning mock address $mockAddress") }
//
// override suspend fun lastBalance(accountId: Int): Wallet.WalletBalance {
// if (transactions.size != 0) {
// val balance = transactions.fold(0L) { acc, tx ->
// if (tx is SentTransaction) acc - tx.value else acc + tx.value
// } - MINERS_FEE_ZATOSHI
// return Wallet.WalletBalance(balance, balance)
// }
// return Wallet.WalletBalance()
// }
//
// /**
// * Uses the [forge] to fabricate a transaction and then walk it through the transaction lifecycle in a useful way.
// * This method will validate the zatoshi amount and toAddress a bit to help with UI validation.
// *
// * @param zatoshi the amount to send. A transaction will be created matching this amount.
// * @param toAddress the address to use. An active transaction will be created matching this address.
// * @param memo the memo to use. This field is ignored.
// * @param fromAccountId the account. This field is ignored.
// */
// override suspend fun sendToAddress(zatoshi: Long, toAddress: String, memo: String, fromAccountId: Int) =
// withContext<Unit>(Dispatchers.IO) {
// Twig.sprout("send")
// val walletTransaction = forge.createSendTransaction(zatoshi)
// val activeTransaction = forge.createActiveSendTransaction(walletTransaction, toAddress)
//
// val isInvalidForTestnet = toAddress.length != 88 && toAddress.startsWith("ztest")
// val isInvalidForMainnet = toAddress.length != 78 && toAddress.startsWith("zs")
//
// val state = when {
// zatoshi < 0 -> TransactionState.Failure(TransactionState.Creating, "amount cannot be negative")
// !toAddress.startsWith("z") -> TransactionState.Failure(
// TransactionState.Creating,
// "address must start with z"
// )
// isInvalidForTestnet -> TransactionState.Failure(TransactionState.Creating, "invalid testnet address")
// isInvalidForMainnet -> TransactionState.Failure(TransactionState.Creating, "invalid mainnet address")
// else -> TransactionState.Creating
// }
// twig("after input validation, state is being set to ${state::class.simpleName}")
// setState(activeTransaction, state)
//
// twig("active tx size is ${activeTransactions.size}")
//
// // next, transition it through the states, if it got created
// if (state !is TransactionState.Creating) {
// twig("failed to create transaction")
// return@withContext
// } else {
// // first, add the transaction
// twig("adding transaction")
// transactionMutex.withLock {
// transactions.add(walletTransaction)
// }
//
// // then update the active transaction through the creation and submission steps
// listOf(TransactionState.Created(walletTransaction.id), TransactionState.SendingToNetwork)
// .forEach { newState ->
// if (!job.isActive) return@withContext
// delay(activeTransactionUpdateFrequency)
// setState(activeTransaction, newState)
// }
//
// // then set the wallet transaction's height (to simulate it being mined)
// val minedHeight = forge.latestHeight.getAndIncrement()
// transactionMutex.withLock {
// transactions.remove(walletTransaction)
// transactions.add(walletTransaction.copy(height = minedHeight, isMined = true))
// }
//
// // simply transition it through the states
// List(11) { TransactionState.AwaitingConfirmations(it) }
// .forEach { newState ->
// if (!job.isActive) return@withContext
// delay(activeTransactionUpdateFrequency)
// activeTransaction.height.set(minedHeight + newState.confirmationCount)
// setState(activeTransaction, newState)
// }
// }
// Twig.clip("send")
// }
//
// /**
// * Helper method to update the state of the given active transaction.
// *
// * @param activeTransaction the transaction to update.
// * @param state the new state to set.
// */
// private suspend fun setState(activeTransaction: ActiveTransaction, state: TransactionState) {
// var copyMap = mutableMapOf<ActiveTransaction, TransactionState>()
// activeTransactionMutex.withLock {
// val currentState = activeTransactions[activeTransaction]
// if ((currentState?.order ?: 0) < 0) {
// twig("ignoring state ${state::class.simpleName} " +
// "because the current state is ${currentState!!::class.simpleName}")
// return
// }
// activeTransactions[activeTransaction] = state
// var count = if (state is TransactionState.AwaitingConfirmations) "(${state.confirmationCount})" else ""
// twig("state set to ${state::class.simpleName}$count on thread ${Thread.currentThread().name}")
// }
//
// copyMap = activeTransactions.toMutableMap()
// twig("sending ${copyMap.size} active transactions")
// launch {
// activeTransactionsChannel.send(copyMap)
// }
// }
//
// /**
// * Sets the state of the given transaction to 'Cancelled'.
// */
// override fun cancelSend(transaction: ActiveSendTransaction): Boolean {
// launch {
// twig("cancelling transaction $transaction")
// setState(transaction, TransactionState.Cancelled)
// }
// return true
// }
//
// /**
// * Utility for forging transactions in both senses of the word.
// */
// private inner class Forge {
// val transactionId = AtomicLong(Random.nextLong(1L..100_000L))
// val latestHeight = AtomicInteger(Random.nextInt(280000..600000))
//
// /**
// * Fire up this forge to begin fabricating transactions.
// */
// fun start(scope: CoroutineScope) {
// scope.launchAddReceiveTransactions()
// scope.launchUpdateTransactionsAndBalance()
// scope.launchUpdateProgress()
// }
//
// /**
// * Take the current list of transactions in the outer class (in a thread-safe way) and send updates to the
// * transaction and balance channels on a regular interval, regardless of what data is present in the
// * transactions collection.
// */
// fun CoroutineScope.launchUpdateTransactionsAndBalance() = launch {
// while (job.isActive) {
// if (transactions.size != 0) {
// var balance = 0L
// transactionMutex.withLock {
// // does not factor in confirmations
// balance =
// transactions.fold(0L) { acc, tx ->
// if (tx.isSend && tx.isMined) acc - tx.value else acc + tx.value
// }
// }
// balanceChannel.send(Wallet.WalletBalance(balance, balance - MINERS_FEE_ZATOSHI))
// }
// // other collaborators add to the list, periodically. This simulates, real-world, non-distinct updates.
// delay(Random.nextLong(transactionInterval / 2))
// var copyList = listOf<ClearedTransaction>()
// transactionMutex.withLock {
// // shallow copy
// copyList = transactions.map { it }
// }
// twig("sending ${copyList.size} transactions")
// transactionsChannel.send(copyList)
// }
// }
//
// /**
// * Periodically create a transaction and add it to the running list of transactions in the outer class, knowing
// * that this list of transactions will be periodically broadcast by the `launchUpdateTransactionsAndBalance`
// * function.
// */
// fun CoroutineScope.launchAddReceiveTransactions() = launch {
// while (job.isActive) {
// delay(transactionInterval)
// transactionMutex.withLock {
// twig("adding received transaction with random value")
// transactions.add(
// createReceiveTransaction()
// .also { twig("adding received transaction with random value: ${it.value}") }
// )
// }
// }
// }
//
// /**
// * Fabricate a stream of progress events.
// */
// fun CoroutineScope.launchUpdateProgress() = launch {
// var progress = 0
// while (job.isActive) {
// delay(initialLoadDuration/100)
// twig("sending progress of $progress")
// progressChannel.send(progress++)
// if(progress > 100) break
// }
// twig("progress channel complete!")
// }
//
// /**
// * Fabricate a receive transaction.
// */
// fun createReceiveTransaction(): ClearedTransaction {
// return ClearedTransaction(
// id = transactionId.getAndIncrement(),
// value = Random.nextLong(20_000L..1_000_000_000L),
// height = latestHeight.getAndIncrement(),
// isSend = false,
// timeInSeconds = System.currentTimeMillis() / 1000,
// isMined = true
// )
// }
//
// /**
// * Fabricate a send transaction.
// */
// fun createSendTransaction(
// amount: Long = Random.nextLong(20_000L..1_000_000_000L),
// txId: Long = -1L
// ): ClearedTransaction {
// return ClearedTransaction(
// id = if (txId == -1L) transactionId.getAndIncrement() else txId,
// value = amount,
// height = null,
// isSend = true,
// timeInSeconds = System.currentTimeMillis() / 1000,
// isMined = false
// )
// }
//
// /**
// * Fabricate an active send transaction, based on the given wallet transaction instance.
// */
// fun createActiveSendTransaction(walletTransaction: ClearedTransaction, toAddress: String)
// = createActiveSendTransaction(walletTransaction.value, toAddress, walletTransaction.id)
//
// /**
// * Fabricate an active send transaction.
// */
// fun createActiveSendTransaction(amount: Long, address: String, txId: Long = -1): ActiveSendTransaction {
// return ActiveSendTransaction(
// transactionId = AtomicLong(if (txId < 0) transactionId.getAndIncrement() else txId),
// toAddress = address,
// value = amount
// )
// }
// }
}

View File

@ -0,0 +1,155 @@
package cash.z.wallet.sdk.data
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
import cash.z.wallet.sdk.db.PendingTransactionDao
import cash.z.wallet.sdk.db.PendingTransactionDb
import cash.z.wallet.sdk.entity.PendingTransaction
import cash.z.wallet.sdk.entity.Transaction
import cash.z.wallet.sdk.ext.EXPIRY_OFFSET
import cash.z.wallet.sdk.service.LightWalletService
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
/**
* Facilitates persistent attempts to ensure a transaction occurs.
*/
// TODO: consider having the manager register the fail listeners rather than having that responsibility spread elsewhere (synchronizer and the broom)
class PersistentTransactionManager(private val db: PendingTransactionDb) : TransactionManager {
private val dao: PendingTransactionDao = db.pendingTransactionDao()
/**
* Constructor that creates the database and then executes a callback on it.
*/
constructor(
appContext: Context,
dataDbName: String = "PendingTransactions.db"
) : this(
Room.databaseBuilder(
appContext,
PendingTransactionDb::class.java,
dataDbName
).setJournalMode(RoomDatabase.JournalMode.TRUNCATE).build()
)
override fun start() {
twig("TransactionManager starting")
}
override fun stop() {
twig("TransactionManager stopping")
db.close()
}
suspend fun initPlaceholder(
zatoshiValue: Long,
toAddress: String,
memo: String
): PendingTransaction? = withContext(IO) {
twig("constructing a placeholder transaction")
val tx = initTransaction(zatoshiValue, toAddress, memo)
twig("done constructing a placeholder transaction")
try {
twig("inserting tx into DB: $tx")
val insertId = dao.insert(tx)
twig("insert returned id of $insertId")
tx.copy(id = insertId)
} catch (t: Throwable) {
val message = "failed initialize a placeholder transaction due to : ${t.message} caused by: ${t.cause}"
twig(message)
null
} finally {
twig("done constructing a placeholder transaction")
}
}
override suspend fun manageCreation(
encoder: TransactionEncoder,
zatoshiValue: Long,
toAddress: String,
memo: String,
currentHeight: Int
): PendingTransaction = manageCreation(encoder, initTransaction(zatoshiValue, toAddress, memo), currentHeight)
suspend fun manageCreation(
encoder: TransactionEncoder,
transaction: PendingTransaction,
currentHeight: Int
): PendingTransaction = withContext(IO){
twig("managing the creation of a transaction")
var tx = transaction.copy(expiryHeight = if (currentHeight == -1) -1 else currentHeight + EXPIRY_OFFSET)
try {
twig("beginning to encode transaction with : $encoder")
val encodedTx = encoder.create(tx.value, tx.toAddress, tx.memo ?: "")
twig("successfully encoded transaction for ${tx.memo}!!")
tx = tx.copy(raw = encodedTx.raw, rawTransactionId = encodedTx.txId)
tx
} catch (t: Throwable) {
val message = "failed to encode transaction due to : ${t.message} caused by: ${t.cause}"
twig(message)
message
tx = tx.copy(errorMessage = message)
tx
} finally {
tx = tx.copy(encodeAttempts = Math.max(1, tx.encodeAttempts + 1))
twig("inserting tx into DB: $tx")
dao.insert(tx)
twig("successfully inserted TX into DB")
tx
}
}
override suspend fun manageSubmission(service: LightWalletService, pendingTransaction: SignedTransaction) {
var tx = pendingTransaction as PendingTransaction
try {
twig("managing the preparation to submit transaction memo: ${tx.memo} amount: ${tx.value}")
val response = service.submitTransaction(pendingTransaction.raw!!)
twig("management of submit transaction completed with response: ${response.errorCode}: ${response.errorMessage}")
tx = if (response.errorCode < 0) {
tx.copy(errorMessage = response.errorMessage, errorCode = response.errorCode)
} else {
tx.copy(errorMessage = null, errorCode = response.errorCode)
}
} catch (t: Throwable) {
twig("error while managing submitting transaction: ${t.message} caused by: ${t.cause}")
} finally {
tx = tx.copy(submitAttempts = Math.max(1, tx.submitAttempts + 1))
dao.insert(tx)
}
}
override suspend fun getAll(): List<PendingTransaction> = withContext(IO) {
dao.getAll()
}
private fun initTransaction(
value: Long,
toAddress: String,
memo: String,
currentHeight: Int = -1
): PendingTransaction {
return PendingTransaction(
value = value,
toAddress = toAddress,
memo = memo,
expiryHeight = if (currentHeight == -1) -1 else currentHeight + EXPIRY_OFFSET
)
}
suspend fun manageMined(pendingTx: PendingTransaction, matchingMinedTx: Transaction) = withContext(IO) {
twig("a pending transaction has been mined!")
val tx = pendingTx.copy(minedHeight = matchingMinedTx.minedHeight)
dao.insert(tx)
}
/**
* Remove a transaction and pretend it never existed.
*/
suspend fun abortTransaction(existingTransaction: PendingTransaction) = withContext(IO) {
dao.delete(existingTransaction)
}
}

View File

@ -0,0 +1,278 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.data.PersistentTransactionSender.ChangeType.*
import cash.z.wallet.sdk.data.TransactionUpdateRequest.RefreshSentTx
import cash.z.wallet.sdk.data.TransactionUpdateRequest.SubmitPendingTx
import cash.z.wallet.sdk.entity.PendingTransaction
import cash.z.wallet.sdk.entity.isMined
import cash.z.wallet.sdk.entity.isPending
import cash.z.wallet.sdk.ext.retryWithBackoff
import cash.z.wallet.sdk.service.LightWalletService
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.actor
import kotlin.math.min
/**
* Monitors pending transactions and sends or retries them, when appropriate.
*/
class PersistentTransactionSender (
private val manager: TransactionManager,
private val service: LightWalletService,
private val ledger: TransactionRepository
) : TransactionSender {
private lateinit var channel: SendChannel<TransactionUpdateRequest>
private var monitoringJob: Job? = null
private val initialMonitorDelay = 45_000L
private var listenerChannel: SendChannel<List<PendingTransaction>>? = null
override var onSubmissionError: ((Throwable) -> Boolean)? = null
private var updateResult: CompletableDeferred<ChangeType>? = null
var lastChangeDetected: ChangeType = NoChange(0)
set(value) {
field = value
val details = when(value) {
is SizeChange -> " from ${value.oldSize} to ${value.newSize}"
is Modified -> " The culprit: ${value.tx}"
is NoChange -> " for the ${value.count.asOrdinal()} time"
else -> ""
}
twig("Checking pending tx detected: ${value.description}$details")
updateResult?.complete(field)
}
fun CoroutineScope.requestUpdate(triggerSend: Boolean) = launch {
if (!channel.isClosedForSend) {
channel.send(if (triggerSend) SubmitPendingTx else RefreshSentTx)
} else {
twig("request ignored because the channel is closed for send!!!")
}
}
/**
* Start an actor that listens for signals about what to do with transactions. This actor's lifespan is within the
* provided [scope] and it will live until the scope is cancelled.
*/
private fun CoroutineScope.startActor() = actor<TransactionUpdateRequest> {
var pendingTransactionDao = 0 // actor state:
for (msg in channel) { // iterate over incoming messages
when (msg) {
is SubmitPendingTx -> updatePendingTransactions()
is RefreshSentTx -> refreshSentTransactions()
}
}
}
private fun CoroutineScope.startMonitor() = launch {
delay(5000) // todo see if we need a formal initial delay
while (!channel.isClosedForSend && isActive) {
// TODO: consider refactoring this since we actually want to wait on the return value of requestUpdate
updateResult = CompletableDeferred()
requestUpdate(true)
updateResult?.await()
delay(calculateDelay())
}
twig("TransactionMonitor stopping!")
}
private fun calculateDelay(): Long {
// if we're actively waiting on results, then poll faster
val delay = when (lastChangeDetected) {
FirstChange -> initialMonitorDelay / 4
is NothingPending, is NoChange -> {
// simple linear offset when there has been no change
val count = (lastChangeDetected as? BackoffEnabled)?.count ?: 0
val offset = initialMonitorDelay / 5L * count
if (previousSentTxs?.isNotEmpty() == true) {
initialMonitorDelay / 4
} else {
initialMonitorDelay
} + offset
}
is SizeChange -> initialMonitorDelay / 4
is Modified -> initialMonitorDelay / 4
}
return min(delay, initialMonitorDelay * 8).also {
twig("Checking for pending tx changes again in ${it/1000L}s")
}
}
override fun start(scope: CoroutineScope) {
twig("TransactionMonitor starting!")
channel = scope.startActor()
monitoringJob?.cancel()
monitoringJob = scope.startMonitor()
}
override fun stop() {
channel.close()
monitoringJob?.cancel()?.also { monitoringJob = null }
manager.stop()
}
override fun notifyOnChange(channel: SendChannel<List<PendingTransaction>>) {
if (channel != null) twig("warning: listener channel was not null but it probably should have been. Something else was listening with $channel!")
listenerChannel = channel
}
/**
* Generates newly persisted information about a transaction so that other processes can send.
*/
override suspend fun sendToAddress(
encoder: TransactionEncoder,
zatoshi: Long,
toAddress: String,
memo: String,
fromAccountId: Int
): PendingTransaction = withContext(IO) {
val currentHeight = service.safeLatestBlockHeight()
(manager as PersistentTransactionManager).manageCreation(encoder, zatoshi, toAddress, memo, currentHeight).also {
requestUpdate(true)
}
}
override suspend fun prepareTransaction(
zatoshiValue: Long,
address: String,
memo: String
): PendingTransaction? = withContext(IO) {
(manager as PersistentTransactionManager).initPlaceholder(zatoshiValue, address, memo).also {
// update UI to show what we've just created. No need to submit, it has no raw data yet!
requestUpdate(false)
}
}
override suspend fun sendPreparedTransaction(
encoder: TransactionEncoder,
tx: PendingTransaction
): PendingTransaction = withContext(IO) {
val currentHeight = service.safeLatestBlockHeight()
(manager as PersistentTransactionManager).manageCreation(encoder, tx, currentHeight).also {
// submit what we've just created
requestUpdate(true)
}
}
override suspend fun cleanupPreparedTransaction(tx: PendingTransaction) {
if (tx.raw.isEmpty()) {
(manager as PersistentTransactionManager).abortTransaction(tx)
}
}
// TODO: get this from the channel instead
var previousSentTxs: List<PendingTransaction>? = null
private suspend fun notifyIfChanged(currentSentTxs: List<PendingTransaction>) = withContext(IO) {
if (hasChanged(previousSentTxs, currentSentTxs) && listenerChannel?.isClosedForSend != true) {
twig("START notifying listenerChannel of changed txs")
listenerChannel?.send(currentSentTxs)
twig("DONE notifying listenerChannel of changed txs")
previousSentTxs = currentSentTxs
} else {
twig("notifyIfChanged: did nothing because ${if(listenerChannel?.isClosedForSend == true) "the channel is closed." else "nothing changed."}")
}
}
override suspend fun cancel(existingTransaction: PendingTransaction) = withContext(IO) {
(manager as PersistentTransactionManager).abortTransaction(existingTransaction). also {
requestUpdate(false)
}
}
private fun hasChanged(
previousSents: List<PendingTransaction>?,
currentSents: List<PendingTransaction>
): Boolean {
// shortcuts first
if (currentSents.isEmpty() && previousSents.isNullOrEmpty()) return false.also {
val count = if (lastChangeDetected is BackoffEnabled) ((lastChangeDetected as? BackoffEnabled)?.count ?: 0) + 1 else 1
lastChangeDetected = NothingPending(count)
}
if (previousSents == null) return true.also { lastChangeDetected = FirstChange }
if (previousSents.size != currentSents.size) return true.also { lastChangeDetected = SizeChange(previousSentTxs?.size ?: -1, currentSents.size) }
for (tx in currentSents) {
// note: implicit .equals check inside `contains` will also detect modifications
if (!previousSents.contains(tx)) return true.also { lastChangeDetected = Modified(tx) }
}
return false.also {
val count = if (lastChangeDetected is BackoffEnabled) ((lastChangeDetected as? BackoffEnabled)?.count ?: 0) + 1 else 1
lastChangeDetected = NoChange(count)
}
}
sealed class ChangeType(val description: String) {
object FirstChange : ChangeType("This is the first time we've seen a change!")
data class NothingPending(override val count: Int) : ChangeType("Nothing happened yet!"), BackoffEnabled
data class NoChange(override val count: Int) : ChangeType("No changes"), BackoffEnabled
class SizeChange(val oldSize: Int, val newSize: Int) : ChangeType("The total number of pending transactions has changed")
class Modified(val tx: PendingTransaction) : ChangeType("At least one transaction has been modified")
}
interface BackoffEnabled {
val count: Int
}
/**
* Check on all sent transactions and if they've changed, notify listeners. This method can be called proactively
* when anything interesting has occurred with a transaction (via [requestUpdate]).
*/
private suspend fun refreshSentTransactions(): List<PendingTransaction> = withContext(IO) {
val allSentTransactions = (manager as PersistentTransactionManager).getAll() // TODO: make this crash and catch error gracefully
notifyIfChanged(allSentTransactions)
allSentTransactions
}
/**
* Submit all pending transactions that have not expired.
*/
private suspend fun updatePendingTransactions() = withContext(IO) {
try {
val allTransactions = refreshSentTransactions()
var pendingCount = 0
val currentHeight = service.safeLatestBlockHeight()
allTransactions.filter { !it.isMined() }.forEach { tx ->
if (tx.isPending(currentHeight)) {
pendingCount++
retryWithBackoff(onSubmissionError, 1000L, 60_000L) {
manager.manageSubmission(service, tx)
}
} else {
tx.rawTransactionId?.let {
ledger.findTransactionByRawId(tx.rawTransactionId)
}?.let {
twig("matching transaction found! $tx")
(manager as PersistentTransactionManager).manageMined(tx, it)
refreshSentTransactions()
}
}
}
twig("given current height $currentHeight, we found $pendingCount pending txs to submit")
} catch (t: Throwable) {
twig("Error during updatePendingTransactions: $t caused by ${t.cause}")
}
}
}
private fun Int.asOrdinal(): String {
return "$this" + if (this % 100 in 11..13) "th" else when(this % 10) {
1 -> "st"
2 -> "nd"
3 -> "rd"
else -> "th"
}
}
private fun LightWalletService.safeLatestBlockHeight(): Int {
return try {
getLatestBlockHeight()
} catch (t: Throwable) {
twig("Warning: LightWalletService failed to return the latest height and we are returning -1 instead.")
-1
}
}
sealed class TransactionUpdateRequest {
object SubmitPendingTx : TransactionUpdateRequest()
object RefreshSentTx : TransactionUpdateRequest()
}

View File

@ -3,16 +3,11 @@ 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.TransactionDao
import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.db.DerivedDataDb
import cash.z.wallet.sdk.db.*
import cash.z.wallet.sdk.entity.ClearedTransaction
import cash.z.wallet.sdk.entity.Transaction
import cash.z.wallet.sdk.exception.RepositoryException
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
/**
* Repository that does polling for simplicity. We will implement an alternative version that uses live data as well as
@ -21,54 +16,29 @@ import kotlinx.coroutines.channels.ReceiveChannel
*/
open class PollingTransactionRepository(
private val derivedDataDb: DerivedDataDb,
private val pollFrequencyMillis: Long = 2000L
private val pollFrequencyMillis: Long = 2000L,
private val limit: Int = Int.MAX_VALUE
) : TransactionRepository {
/**
* Constructor that creates the database and then executes a callback on it.
* Constructor that creates the database.
*/
constructor(
context: Context,
dataDbName: String,
pollFrequencyMillis: Long = 2000L,
dbCallback: (DerivedDataDb) -> Unit = {}
pollFrequencyMillis: Long = 2000L
) : this(
Room.databaseBuilder(context, DerivedDataDb::class.java, dataDbName)
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
.build(),
pollFrequencyMillis
) {
dbCallback(derivedDataDb)
}
)
internal val blocks: BlockDao = derivedDataDb.blockDao()
private val blocks: BlockDao = derivedDataDb.blockDao()
private val receivedNotes: ReceivedDao = derivedDataDb.receivedDao()
private val sentNotes: SentDao = derivedDataDb.sentDao()
private val transactions: TransactionDao = derivedDataDb.transactionDao()
private lateinit var pollingJob: Job
private val allTransactionsChannel = ConflatedBroadcastChannel<List<WalletTransaction>>()
private val existingTransactions = listOf<WalletTransaction>()
private val wasPreviouslyStarted
get() = !existingTransactions.isEmpty() || 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 but doing nothing")
pollingJob.cancel()
// TODO: verify that the channels behave as expected in this scenario
}
override fun allTransactions(): ReceiveChannel<List<WalletTransaction>> {
return allTransactionsChannel.openSubscription()
}
protected var pollingJob: Job? = null
override fun lastScannedHeight(): Int {
return blocks.lastScannedHeight()
@ -85,53 +55,69 @@ open class PollingTransactionRepository(
transaction
}
override suspend fun findTransactionByRawId(rawTxId: ByteArray): Transaction? = withContext(IO) {
transactions.findByRawId(rawTxId)
}
override suspend fun deleteTransactionById(txId: Long) = withContext(IO) {
twigTask("deleting transaction with id $txId") {
transactions.deleteById(txId)
}
}
override suspend fun getClearedTransactions(): List<ClearedTransaction> = withContext(IO) {
transactions.getSentTransactions(limit) + transactions.getReceivedTransactions(limit)
}
private suspend fun poll() = withContext(IO) {
var previousTransactions: List<WalletTransaction>? = null
while (isActive && !allTransactionsChannel.isClosedForSend) {
twigTask("polling for transactions every ${pollFrequencyMillis}ms") {
val newTransactions = transactions.getAll()
override suspend fun monitorChanges(listener: () -> Unit) = withContext(IO) {
// since the only thing mutable is unmined transactions, we can simply check for new data rows rather than doing any deep comparisons
// in the future we can leverage triggers instead
pollingJob?.cancel()
pollingJob = launch {
val txCount = ValueHolder(-1, "Transaction Count")
val unminedCount = ValueHolder(-1, "Unmined Transaction Count")
val sentCount = ValueHolder(-1, "Sent Transaction Count")
val receivedCount = ValueHolder(-1, "Received Transaction Count")
if (hasChanged(previousTransactions, newTransactions)) {
twig("loaded ${newTransactions.count()} transactions and changes were detected!")
allTransactionsChannel.send(newTransactions)
previousTransactions = newTransactions
while (coroutineContext.isActive) {
// we check all conditions to avoid duplicate notifications whenever a change impacts multiple tables
// if counting becomes slower than the blocktime (highly unlikely) then this could be optimized to call the listener early and continue counting afterward but there's no need for that complexity now
if (txCount.changed(transactions.count())
|| unminedCount.changed(transactions.countUnmined())
|| sentCount.changed(sentNotes.count())
|| receivedCount.changed(receivedNotes.count())
) {
twig("Notifying listener that changes have been detected in transactions!")
listener.invoke()
} else {
twig("loaded ${newTransactions.count()} transactions but no changes detected.")
twig("No changes detected in transactions.")
}
delay(pollFrequencyMillis)
}
delay(pollFrequencyMillis)
}
twig("Done polling for transactions")
}
private fun hasChanged(oldTxs: List<WalletTransaction>?, newTxs: List<WalletTransaction>): Boolean {
fun pr(t: List<WalletTransaction>?): String {
if(t == null) return "none"
val str = StringBuilder()
for (tx in t) {
str.append("\n@TWIG: ").append(tx.toString())
}
return str.toString()
}
val sends = newTxs.filter { it.isSend }
if(sends.isNotEmpty()) twig("SENDS hasChanged: old-txs: ${pr(oldTxs?.filter { it.isSend })}\n@TWIG: new-txs: ${pr(sends)}")
// shortcuts first
if (newTxs.isEmpty() && oldTxs == null) return false.also { twig("detected nothing happened yet") } // if nothing has happened, that doesn't count as a change
if (oldTxs == null) return true.also { twig("detected first set of txs!") } // the first set of transactions is automatically a change
if (oldTxs.size != newTxs.size) return true.also { twig("detected size difference") } // can't be the same and have different sizes, duh
for (note in newTxs) {
if (!oldTxs.contains(note)) return true.also { twig("detected change for $note") }
}
return false.also { twig("detected no changes in all new txs") }
fun stop() {
pollingJob?.cancel().also { pollingJob = null }
derivedDataDb.close()
}
}
}
/**
* Reduces some of the boilerplate of checking a value for changes.
*/
internal class ValueHolder<T>(var value: T, val description: String = "Value") {
/**
* Hold the new value and report whether it has changed.
*/
fun changed(newValue: T): Boolean {
return if (newValue == value) {
false
} else {
twig("$description changed from $value to $newValue")
value = newValue
true
}
}
}

View File

@ -1,10 +0,0 @@
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()
}
}

View File

@ -1,17 +0,0 @@
package cash.z.wallet.sdk.data
import java.lang.IllegalStateException
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class SampleSpendingKeyProvider(private val seedValue: String) : ReadWriteProperty<Any?, String> {
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
}
override fun getValue(thisRef: Any?, property: KProperty<*>): String {
// dynamically generating keys, based on seed is out of scope for this sample
if(seedValue != "dummyseed") throw IllegalStateException("This sample key provider only supports the dummy seed")
return "secret-extended-key-test1q0f0urnmqqqqpqxlree5urprcmg9pdgvr2c88qhm862etv65eu84r9zwannpz4g88299xyhv7wf9xkecag653jlwwwyxrymfraqsnz8qfgds70qjammscxxyl7s7p9xz9w906epdpy8ztsjd7ez7phcd5vj7syx68sjskqs8j9lef2uuacghsh8puuvsy9u25pfvcdznta33qe6xh5lrlnhdkgymnpdug4jm6tpf803cad6tqa9c0ewq9l03fqxatevm97jmuv8u0ccxjews5"
}
}

View File

@ -1,395 +1,305 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.data.SdkSynchronizer.SyncState.*
import cash.z.wallet.sdk.exception.SynchronizerException
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.distinct
import kotlin.coroutines.CoroutineContext
/**
* 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.
*
* @param downloader the component that downloads compact blocks and exposes them as a stream
* @param processor the component that saves the downloaded compact blocks to the cache and then scans those blocks for
* data related to this wallet.
* @param repository the component that exposes streams of wallet transaction information.
* @param activeTransactionManager the component that manages the lifecycle of active transactions. This includes sent
* transactions that have not been mined.
* @param wallet the component that wraps the JNI layer that interacts with librustzcash and manages wallet config.
* @param batchSize the number of compact blocks to download at a time.
* @param staleTolerance the number of blocks to allow before considering our data to be stale
* @param blockPollFrequency how often to poll for compact blocks. Once all missing blocks have been downloaded, this
* number represents the number of milliseconds the synchronizer will wait before checking for newly mined blocks.
*/
class SdkSynchronizer(
private val downloader: CompactBlockStream,
private val processor: CompactBlockProcessor,
private val repository: TransactionRepository,
private val activeTransactionManager: ActiveTransactionManager,
private val wallet: Wallet,
private val batchSize: Int = 1000,
private val staleTolerance: Int = 10,
private val blockPollFrequency: Long = CompactBlockStream.DEFAULT_POLL_INTERVAL
) : Synchronizer {
/**
* The primary job for this Synchronizer. It leverages structured concurrency to cancel all work when the
* `parentScope` provided to the [start] method ends.
*/
private lateinit var blockJob: Job
/**
* The state this Synchronizer was in when it started. This is helpful because the conditions that lead to FirstRun
* or isStale being detected can change quickly so retaining the initial state is useful for walkthroughs or other
* elements of an app that need to rely on this information later, rather than in realtime.
*/
private lateinit var initialState: SyncState
/**
* Returns true when `start` has been called on this synchronizer.
*/
private val wasPreviouslyStarted
get() = ::blockJob.isInitialized
/**
* Retains the error that caused this synchronizer to fail for future error handling or reporting.
*/
private var failure: Throwable? = null
/**
* The default exception handler for the block job. Calls [onException].
*/
private val exceptionHandler: (c: CoroutineContext, t: Throwable) -> Unit = { _, t -> onException(t) }
/**
* Sets a listener to be notified of uncaught Synchronizer errors. When null, errors will only be logged.
*/
override var onSynchronizerErrorListener: ((Throwable?) -> Boolean)? = null
set(value) {
field = value
if (failure != null) value?.invoke(failure)
}
// This has been replaced by "StableSynchronizer" We keep it around for the docs
//
// Public API
//
/* Lifecycle */
/**
* Starts this synchronizer within the given scope. For simplicity, attempting to start an instance that has already
* been started will throw a [SynchronizerException.FalseStart] exception. This reduces the complexity of managing
* resources that must be recycled. Instead, each synchronizer is designed to have a long lifespan and should be
* started from an activity, application or session.
*
* @param parentScope the scope to use for this synchronizer, typically something with a lifecycle such as an
* Activity for single-activity apps or a logged in user session. This scope is only used for launching this
* synchronzer's job as a child.
*/
override fun start(parentScope: CoroutineScope): Synchronizer {
// prevent restarts so the behavior of this class is easier to reason about
if (wasPreviouslyStarted) throw SynchronizerException.FalseStart
twig("starting")
failure = null
blockJob = parentScope.launch(CoroutineExceptionHandler(exceptionHandler)) {
supervisorScope {
continueWithState(determineState())
}
}
return this
}
/**
* Stops this synchronizer by stopping the downloader, repository, and activeTransactionManager, then cancelling the
* parent job. Note that we do not cancel the parent scope that was passed into [start] because the synchronizer
* does not own that scope, it just uses it for launching children.
*/
override fun stop() {
twig("stopping")
downloader.stop().also { twig("downloader stopped") }
repository.stop().also { twig("repository stopped") }
activeTransactionManager.stop().also { twig("activeTransactionManager stopped") }
// TODO: investigate whether this is necessary and remove or improve, accordingly
Thread.sleep(5000L)
blockJob.cancel().also { twig("blockJob cancelled") }
}
/* Channels */
/**
* A stream of all the wallet transactions, delegated to the [activeTransactionManager].
*/
override fun activeTransactions() = activeTransactionManager.subscribe()
/**
* A stream of all the wallet transactions, delegated to the [repository].
*/
override fun allTransactions(): ReceiveChannel<List<WalletTransaction>> {
return repository.allTransactions()
}
/**
* A stream of progress values, corresponding to this Synchronizer downloading blocks, delegated to the
* [downloader]. Any non-zero value below 100 indicates that progress indicators can be shown and a value of 100
* signals that progress is complete and any progress indicators can be hidden. At that point, the synchronizer
* switches from catching up on missed blocks to periodically monitoring for newly mined blocks.
*/
override fun progress(): ReceiveChannel<Int> {
return downloader.progress()
}
/**
* A stream of balance values, delegated to the [wallet].
*/
override fun balances(): ReceiveChannel<Wallet.WalletBalance> {
return wallet.balances()
}
/* Status */
/**
* A flag to indicate that this Synchronizer is significantly out of sync with it's server. This is determined by
* the delta between the current block height reported by the server and the latest block we have stored in cache.
* Whenever this delta is greater than the [staleTolerance], this function returns true. This is intended for
* showing progress indicators when the user returns to the app after having not used it for a long period.
* Typically, this means the user may have to wait for downloading to occur and the current balance and transaction
* information cannot be trusted as 100% accurate.
*
* @return true when the local data is significantly out of sync with the remote server and the app data is stale.
*/
override suspend fun isStale(): Boolean = withContext(IO) {
val latestBlockHeight = downloader.connection.getLatestBlockHeight()
val ourHeight = processor.cacheDao.latestBlockHeight()
val tolerance = 10
val delta = latestBlockHeight - ourHeight
twig("checking whether out of sync. " +
"LatestHeight: $latestBlockHeight ourHeight: $ourHeight Delta: $delta tolerance: $tolerance")
delta > tolerance
}
/**
* A flag to indicate that the initial state of this synchronizer was firstRun. This is useful for knowing whether
* initializing the database is required and whether to show things like"first run walk-throughs."
*
* @return true when this synchronizer has not been run before on this device or when cache has been cleared since
* the last run.
*/
override suspend fun isFirstRun(): Boolean = withContext(IO) {
initialState is FirstRun
}
/* Operations */
/**
* Gets the address for the given account.
*
* @param accountId the optional accountId whose address of interest. Typically, this value is zero.
*/
override fun getAddress(accountId: Int): String = wallet.getAddress()
/**
* Gets the available balance for the given account. In most cases, the stream of balances provided by [balances]
* should be used instead of this function.
*
* @param accountId the optional accountId whose available balance is of interest. Typically, this value is zero.
*/
override fun getAvailableBalance(accountId: Int): Long = wallet.availableBalanceSnapshot(accountId)
/**
* Sends zatoshi.
*
* @param zatoshi the amount of zatoshi to send.
* @param toAddress the recipient's address.
* @param memo the optional memo to include as part of the transaction.
* @param fromAccountId the optional account id to use. By default, the first account is used.
*/
override suspend fun sendToAddress(zatoshi: Long, toAddress: String, memo: String, fromAccountId: Int) =
activeTransactionManager.sendToAddress(zatoshi, toAddress, memo, fromAccountId)
/**
* Attempts to cancel a previously sent transaction. Transactions can only be cancelled during the calculation phase
* before they've been submitted to the server. This method will return false when it is too late to cancel. This
* logic is delegated to the activeTransactionManager, which knows the state of the given transaction.
*
* @param transaction the transaction to cancel.
* @return true when the cancellation request was successful. False when it is too late to cancel.
*/
override fun cancelSend(transaction: ActiveSendTransaction): Boolean = activeTransactionManager.cancel(transaction)
//
// Private API
//
/**
* After determining the initial state, continue based on those findings.
*
* @param syncState the sync state found
*/
private fun CoroutineScope.continueWithState(syncState: SyncState): Job {
return when (syncState) {
FirstRun -> onFirstRun()
is CacheOnly -> onCacheOnly(syncState)
is ReadyToProcess -> onReady(syncState)
}
}
/**
* Logic for the first run. This is when the wallet gets initialized, which includes setting up the dataDB and
* preloading it with data corresponding to the wallet birthday.
*/
private fun CoroutineScope.onFirstRun(): Job {
twig("this appears to be a fresh install, beginning first run of application")
val firstRunStartHeight = wallet.initialize() // should get the latest sapling tree and return that height
twig("wallet firstRun returned a value of $firstRunStartHeight")
return continueWithState(ReadyToProcess(firstRunStartHeight))
}
/**
* Logic for starting the Synchronizer when no scans have yet occurred. Takes care of initializing the dataDb and
* then
*/
private fun CoroutineScope.onCacheOnly(syncState: CacheOnly): Job {
twig("we have cached blocks but no data DB, beginning pre-cached version of application")
val firstRunStartHeight = wallet.initialize(syncState.startingBlockHeight)
twig("wallet has already cached up to a height of $firstRunStartHeight")
return continueWithState(ReadyToProcess(firstRunStartHeight))
}
/**
* Logic for starting the Synchronizer once it is ready for processing. All starts eventually end with this method.
*/
private fun CoroutineScope.onReady(syncState: ReadyToProcess) = launch {
twig("synchronization is ready to begin at height ${syncState.startingBlockHeight}")
// TODO: for PIR concerns, introduce some jitter here for where, exactly, the downloader starts
val blockChannel =
downloader.start(
this,
syncState.startingBlockHeight,
batchSize,
pollFrequencyMillis = blockPollFrequency
)
launch { monitorProgress(downloader.progress()) }
launch { monitorTransactions(repository.allTransactions().distinct()) }
activeTransactionManager.start()
repository.start(this)
processor.processBlocks(blockChannel)
}
/**
* Monitor download progress in order to trigger a scan the moment all blocks have been received. This reduces the
* amount of time it takes to get accurate balance information since scan intervals are fairly long.
*/
private suspend fun monitorProgress(progressChannel: ReceiveChannel<Int>) = withContext(IO) {
twig("beginning to monitor download progress")
for (i in progressChannel) {
if(i >= 100) {
twig("triggering a proactive scan in a second because all missing blocks have been loaded")
delay(1000L)
launch {
twig("triggering proactive scan!")
processor.scanBlocks()
twig("done triggering proactive scan!")
}
break
}
}
twig("done monitoring download progress")
}
/**
* Monitors transactions and recalculates the balance any time transactions have changed.
*/
private suspend fun monitorTransactions(transactionChannel: ReceiveChannel<List<WalletTransaction>>) =
withContext(IO) {
twig("beginning to monitor transactions in order to update the balance")
for (i in transactionChannel) {
twig("triggering a balance update because transactions have changed")
wallet.sendBalanceInfo()
twig("done triggering balance check!")
}
twig("done monitoring transactions in order to update the balance")
}
/**
* Determine the initial state of the data by checking whether the dataDB is initialized and the last scanned block
* height. This is considered a first run if no blocks have been processed.
*/
private suspend fun determineState(): SyncState = withContext(IO) {
twig("determining state (has the app run before, what block did we last see, etc.)")
initialState = if (processor.dataDbExists) {
val isInitialized = repository.isInitialized()
// this call blocks because it does IO
val startingBlockHeight = Math.max(processor.lastProcessedBlock(), repository.lastScannedHeight())
twig("cacheDb exists with last height of $startingBlockHeight and isInitialized = $isInitialized")
if (!repository.isInitialized()) FirstRun else ReadyToProcess(startingBlockHeight)
} else if(processor.cachDbExists) {
// this call blocks because it does IO
val startingBlockHeight = processor.lastProcessedBlock()
twig("cacheDb exists with last height of $startingBlockHeight")
if (startingBlockHeight <= 0) FirstRun else CacheOnly(startingBlockHeight)
} else {
FirstRun
}
twig("determined ${initialState::class.java.simpleName}")
initialState
}
/**
* Wraps exceptions, logs them and then invokes the [onSynchronizerErrorListener], if it exists.
*/
private fun onException(throwable: Throwable) {
twig("********")
twig("******** ERROR: $throwable")
if (throwable.cause != null) twig("******** caused by ${throwable.cause}")
if (throwable.cause?.cause != null) twig("******** caused by ${throwable.cause?.cause}")
twig("********")
val hasRecovered = onSynchronizerErrorListener?.invoke(throwable)
if (hasRecovered != true) stop().also { failure = throwable }
}
/**
* Represents the initial state of the Synchronizer.
*/
sealed class SyncState {
/**
* State for the first run of the Synchronizer, when the database has not been initialized.
*/
object FirstRun : SyncState()
/**
* State for when compact blocks have been downloaded but not scanned. This state is typically achieved when the
* app was previously started but killed before the first scan took place. In this case, we do not need to
* download compact blocks that we already have.
*
* @param startingBlockHeight the last block that has been downloaded into the cache. We do not need to download
* any blocks before this height because we already have them.
*/
class CacheOnly(val startingBlockHeight: Int = Int.MAX_VALUE) : SyncState()
/**
* The final state of the Synchronizer, when all initialization is complete and the starting block is known.
*
* @param startingBlockHeight the height that will be fed to the downloader. In most cases, it will represent
* either the wallet birthday or the last block that was processed in the previous session.
*/
class ReadyToProcess(val startingBlockHeight: Int = Int.MAX_VALUE) : SyncState()
}
}
//package cash.z.wallet.sdk.data
//
//import cash.z.wallet.sdk.block.CompactBlockProcessor
//import cash.z.wallet.sdk.entity.ClearedTransaction
//import cash.z.wallet.sdk.exception.SynchronizerException
//import cash.z.wallet.sdk.exception.WalletException
//import cash.z.wallet.sdk.secure.Wallet
//import kotlinx.coroutines.*
//import kotlinx.coroutines.Dispatchers.IO
//import kotlinx.coroutines.channels.ConflatedBroadcastChannel
//import kotlinx.coroutines.channels.ReceiveChannel
//import kotlinx.coroutines.channels.distinct
//import kotlin.coroutines.CoroutineContext
//
///**
// * 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.
// *
// * @param processor the component that saves the downloaded compact blocks to the cache and then scans those blocks for
// * data related to this wallet.
// * @param repository the component that exposes streams of wallet transaction information.
// * @param activeTransactionManager the component that manages the lifecycle of active transactions. This includes sent
// * transactions that have not been mined.
// * @param wallet the component that wraps the JNI layer that interacts with librustzcash and manages wallet config.
// * @param batchSize the number of compact blocks to download at a time.
// * @param staleTolerance the number of blocks to allow before considering our data to be stale
// * @param blockPollFrequency how often to poll for compact blocks. Once all missing blocks have been downloaded, this
// * number represents the number of milliseconds the synchronizer will wait before checking for newly mined blocks.
// */
//class SdkSynchronizer(
// private val processor: CompactBlockProcessor,
// private val repository: TransactionRepository,
// private val activeTransactionManager: ActiveTransactionManager,
// private val wallet: Wallet,
// private val staleTolerance: Int = 10
//) : Synchronizer {
//
// /**
// * The primary job for this Synchronizer. It leverages structured concurrency to cancel all work when the
// * `parentScope` provided to the [start] method ends.
// */
// private lateinit var blockJob: Job
//
// /**
// * The state this Synchronizer was in when it started. This is helpful because the conditions that lead to FirstRun
// * or isStale being detected can change quickly so retaining the initial state is useful for walkthroughs or other
// * elements of an app that need to rely on this information later, rather than in realtime.
// */
// private lateinit var initialState: SyncState
//
// /**
// * Returns true when `start` has been called on this synchronizer.
// */
// private val wasPreviouslyStarted
// get() = ::blockJob.isInitialized
//
// /**
// * Retains the error that caused this synchronizer to fail for future error handling or reporting.
// */
// private var failure: Throwable? = null
//
// /**
// * The default exception handler for the block job. Calls [onException].
// */
// private val exceptionHandler: (c: CoroutineContext, t: Throwable) -> Unit = { _, t -> onException(t) }
//
// /**
// * Sets a listener to be notified of uncaught Synchronizer errors. When null, errors will only be logged.
// */
// override var onSynchronizerErrorListener: ((Throwable?) -> Boolean)? = null
// set(value) {
// field = value
// if (failure != null) value?.invoke(failure)
// }
//
// /**
// * Channel of transactions from the repository.
// */
// private val transactionChannel = ConflatedBroadcastChannel<List<ClearedTransaction>>()
//
// /**
// * Channel of balance information.
// */
// private val balanceChannel = ConflatedBroadcastChannel<Wallet.WalletBalance>()
//
// //
// // Public API
// //
//
// /* Lifecycle */
//
// /**
// * Starts this synchronizer within the given scope. For simplicity, attempting to start an instance that has already
// * been started will throw a [SynchronizerException.FalseStart] exception. This reduces the complexity of managing
// * resources that must be recycled. Instead, each synchronizer is designed to have a long lifespan and should be
// * started from an activity, application or session.
// *
// * @param parentScope the scope to use for this synchronizer, typically something with a lifecycle such as an
// * Activity for single-activity apps or a logged in user session. This scope is only used for launching this
// * synchronzer's job as a child.
// */
// override fun start(parentScope: CoroutineScope): Synchronizer {
// // prevent restarts so the behavior of this class is easier to reason about
// if (wasPreviouslyStarted) throw SynchronizerException.FalseStart
// twig("starting")
// failure = null
// blockJob = parentScope.launch(CoroutineExceptionHandler(exceptionHandler)) {
// supervisorScope {
// try {
// wallet.initialize()
// } catch (e: WalletException.AlreadyInitializedException) {
// twig("Warning: wallet already initialized but this is safe to ignore " +
// "because the SDK now automatically detects where to start downloading.")
// }
// onReady()
// }
// }
// return this
// }
//
// /**
// * Stops this synchronizer by stopping the downloader, repository, and activeTransactionManager, then cancelling the
// * parent job. Note that we do not cancel the parent scope that was passed into [start] because the synchronizer
// * does not own that scope, it just uses it for launching children.
// */
// override fun stop() {
// twig("stopping")
// (repository as? PollingTransactionRepository)?.stop().also { twig("repository stopped") }
// activeTransactionManager.stop().also { twig("activeTransactionManager stopped") }
// // TODO: investigate whether this is necessary and remove or improve, accordingly
// Thread.sleep(5000L)
// blockJob.cancel().also { twig("blockJob cancelled") }
// }
//
//
// /* Channels */
//
// /**
// * A stream of all the wallet transactions, delegated to the [activeTransactionManager].
// */
// override fun activeTransactions() = activeTransactionManager.subscribe()
//
// /**
// * A stream of all the wallet transactions, delegated to the [repository].
// */
// override fun allTransactions(): ReceiveChannel<List<ClearedTransaction>> {
// return transactionChannel.openSubscription()
// }
//
// /**
// * A stream of progress values, corresponding to this Synchronizer downloading blocks, delegated to the
// * [downloader]. Any non-zero value below 100 indicates that progress indicators can be shown and a value of 100
// * signals that progress is complete and any progress indicators can be hidden. At that point, the synchronizer
// * switches from catching up on missed blocks to periodically monitoring for newly mined blocks.
// */
// override fun progress(): ReceiveChannel<Int> {
// return processor.progress()
// }
//
// /**
// * A stream of balance values, delegated to the [wallet].
// */
// override fun balances(): ReceiveChannel<Wallet.WalletBalance> {
// return balanceChannel.openSubscription()
// }
//
//
// /* Status */
//
// /**
// * A flag to indicate that this Synchronizer is significantly out of sync with it's server. This is determined by
// * the delta between the current block height reported by the server and the latest block we have stored in cache.
// * Whenever this delta is greater than the [staleTolerance], this function returns true. This is intended for
// * showing progress indicators when the user returns to the app after having not used it for a long period.
// * Typically, this means the user may have to wait for downloading to occur and the current balance and transaction
// * information cannot be trusted as 100% accurate.
// *
// * @return true when the local data is significantly out of sync with the remote server and the app data is stale.
// */
// override suspend fun isStale(): Boolean = withContext(IO) {
// val latestBlockHeight = processor.downloader.getLatestBlockHeight()
// val ourHeight = processor.downloader.getLastDownloadedHeight()
// val tolerance = staleTolerance
// val delta = latestBlockHeight - ourHeight
// twig("checking whether out of sync. " +
// "LatestHeight: $latestBlockHeight ourHeight: $ourHeight Delta: $delta tolerance: $tolerance")
// delta > tolerance
// }
//
// /* Operations */
//
// /**
// * Gets the address for the given account.
// *
// * @param accountId the optional accountId whose address of interest. Typically, this value is zero.
// */
// override fun getAddress(accountId: Int): String = wallet.getAddress()
//
// override suspend fun getBalance(accountId: Int): Wallet.WalletBalance = wallet.getBalanceInfo(accountId)
//
// /**
// * Sends zatoshi.
// *
// * @param zatoshi the amount of zatoshi to send.
// * @param toAddress the recipient's address.
// * @param memo the optional memo to include as part of the transaction.
// * @param fromAccountId the optional account id to use. By default, the first account is used.
// */
// override suspend fun sendToAddress(zatoshi: Long, toAddress: String, memo: String, fromAccountId: Int) =
// activeTransactionManager.sendToAddress(zatoshi, toAddress, memo, fromAccountId)
//
// /**
// * Attempts to cancel a previously sent transaction. Transactions can only be cancelled during the calculation phase
// * before they've been submitted to the server. This method will return false when it is too late to cancel. This
// * logic is delegated to the activeTransactionManager, which knows the state of the given transaction.
// *
// * @param transaction the transaction to cancel.
// * @return true when the cancellation request was successful. False when it is too late to cancel.
// */
// override fun cancelSend(transaction: ActiveSendTransaction): Boolean = activeTransactionManager.cancel(transaction)
//
//
// //
// // Private API
// //
//
//
// /**
// * Logic for starting the Synchronizer once it is ready for processing. All starts eventually end with this method.
// */
// private fun CoroutineScope.onReady() = launch {
// twig("synchronization is ready to begin!")
// launch { monitorTransactions(transactionChannel.openSubscription().distinct()) }
//
// activeTransactionManager.start()
// repository.poll(transactionChannel)
// processor.start()
// }
//
// /**
// * Monitors transactions and recalculates the balance any time transactions have changed.
// */
// private suspend fun monitorTransactions(transactionChannel: ReceiveChannel<List<ClearedTransaction>>) =
// withContext(IO) {
// twig("beginning to monitor transactions in order to update the balance")
// launch {
// for (i in transactionChannel) {
// twig("triggering a balance update because transactions have changed")
// balanceChannel.send(wallet.getBalanceInfo())
// twig("done triggering balance check!")
// }
// }
// twig("done monitoring transactions in order to update the balance")
// }
//
// /**
// * Wraps exceptions, logs them and then invokes the [onSynchronizerErrorListener], if it exists.
// */
// private fun onException(throwable: Throwable) {
// twig("********")
// twig("******** ERROR: $throwable")
// if (throwable.cause != null) twig("******** caused by ${throwable.cause}")
// if (throwable.cause?.cause != null) twig("******** caused by ${throwable.cause?.cause}")
// twig("********")
//
// val hasRecovered = onSynchronizerErrorListener?.invoke(throwable)
// if (hasRecovered != true) stop().also { failure = throwable }
// }
//
// /**
// * Represents the initial state of the Synchronizer.
// */
// sealed class SyncState {
// /**
// * State for the first run of the Synchronizer, when the database has not been initialized.
// */
// object FirstRun : SyncState()
//
// /**
// * State for when compact blocks have been downloaded but not scanned. This state is typically achieved when the
// * app was previously started but killed before the first scan took place. In this case, we do not need to
// * download compact blocks that we already have.
// *
// * @param startingBlockHeight the last block that has been downloaded into the cache. We do not need to download
// * any blocks before this height because we already have them.
// */
// class CacheOnly(val startingBlockHeight: Int = Int.MAX_VALUE) : SyncState()
//
// /**
// * The final state of the Synchronizer, when all initialization is complete and the starting block is known.
// *
// * @param startingBlockHeight the height that will be fed to the downloader. In most cases, it will represent
// * either the wallet birthday or the last block that was processed in the previous session.
// */
// class ReadyToProcess(val startingBlockHeight: Int = Int.MAX_VALUE) : SyncState()
// }
//
//}

View File

@ -1,14 +0,0 @@
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
}
}

View File

@ -0,0 +1,261 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.block.CompactBlockProcessor
import cash.z.wallet.sdk.entity.ClearedTransaction
import cash.z.wallet.sdk.entity.PendingTransaction
import cash.z.wallet.sdk.entity.SentTransaction
import cash.z.wallet.sdk.exception.WalletException
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlin.coroutines.CoroutineContext
/**
* A synchronizer that attempts to remain operational, despite any number of errors that can occur.
*/
@ExperimentalCoroutinesApi
class StableSynchronizer (
private val wallet: Wallet,
private val ledger: TransactionRepository,
private val sender: TransactionSender,
private val processor: CompactBlockProcessor,
private val encoder: TransactionEncoder
) : Synchronizer {
/**
* The lifespan of this Synchronizer. This scope is initialized once the Synchronizer starts because it will be a
* child of the parentScope that gets passed into the [start] function. Everything launched by this Synchronizer
* will be cancelled once the Synchronizer or its parentScope stops. This is a lateinit rather than nullable
* property so that it fails early rather than silently, whenever the scope is used before the Synchronizer has been
* started.
*/
lateinit var coroutineScope: CoroutineScope
//
// Communication Primitives
//
private val balanceChannel = ConflatedBroadcastChannel(Wallet.WalletBalance())
private val progressChannel = ConflatedBroadcastChannel(0)
private val pendingChannel = ConflatedBroadcastChannel<List<PendingTransaction>>(listOf())
private val clearedChannel = ConflatedBroadcastChannel<List<ClearedTransaction>>(listOf())
//
// Status
//
override val isConnected: Boolean get() = processor.isConnected
override val isSyncing: Boolean get() = processor.isSyncing
override val isScanning: Boolean get() = processor.isScanning
//
// Error Handling
//
/*
* These listeners will not be called on the main thread.
* So they will need to switch to do anything with UI, like dialogs
*/
override var onCriticalErrorHandler: ((Throwable?) -> Boolean)? = null
override var onProcessorErrorHandler: ((Throwable?) -> Boolean)? = null
override var onSubmissionErrorHandler: ((Throwable?) -> Boolean)? = null
override fun start(parentScope: CoroutineScope): Synchronizer {
// base this scope on the parent so that when the parent's job cancels, everything here cancels as well
// also use a supervisor job so that one failure doesn't bring down the whole synchronizer
coroutineScope = CoroutineScope(SupervisorJob(parentScope.coroutineContext[Job]!!) + Dispatchers.Main)
// TODO: this doesn't work as intended. Refactor to improve the cancellation behavior (i.e. what happens when one job fails) by making launchTransactionMonitor throw an exception
coroutineScope.launch {
initWallet()
startSender(this)
launchProgressMonitor()
launchPendingMonitor()
launchTransactionMonitor()
onReady()
}
return this
}
private fun startSender(parentScope: CoroutineScope) {
sender.onSubmissionError = ::onFailedSend
sender.start(parentScope)
}
private suspend fun initWallet() = withContext(Dispatchers.IO) {
try {
wallet.initialize()
} catch (e: WalletException.AlreadyInitializedException) {
twig("Warning: wallet already initialized but this is safe to ignore " +
"because the SDK automatically detects where to start downloading.")
} catch (f: WalletException.FalseStart) {
if (recoverFrom(f)) {
twig("Warning: had a wallet init error but we recovered!")
} else {
twig("Error: false start while initializing wallet!")
}
}
}
private fun recoverFrom(error: WalletException.FalseStart): Boolean {
if (error.message?.contains("unable to open database file") == true
|| error.message?.contains("table blocks has no column named") == true) {
//TODO: these errors are fatal and we need to delete the database and start over
twig("Database should be deleted and we should start over")
}
return false
}
override fun stop() {
coroutineScope.cancel()
}
//
// Monitors
//
// begin the monitor that will update the balance proactively whenever we're done a large scan
private fun CoroutineScope.launchProgressMonitor(): Job = launch {
twig("launching progress monitor")
val progressUpdates = progress()
for (progress in progressUpdates) {
if (progress == 100) {
twig("triggering a balance update because progress is complete")
refreshBalance()
}
}
twig("done monitoring for progress changes")
}
// begin the monitor that will output pending transactions into the pending channel
private fun CoroutineScope.launchPendingMonitor(): Job = launch {
twig("launching pending monitor")
// ask to be notified when the sender notices anything new, while attempting to send
sender.notifyOnChange(pendingChannel)
// when those notifications come in, also update the balance
val channel = pendingChannel.openSubscription()
for (pending in channel) {
if(balanceChannel.isClosedForSend) break
twig("triggering a balance update because pending transactions have changed")
refreshBalance()
}
twig("done monitoring for pending changes and balance changes")
}
private fun CoroutineScope.launchTransactionMonitor(): Job = launch {
ledger.monitorChanges(::onTransactionsChanged)
}
fun onTransactionsChanged() {
coroutineScope.launch {
refreshBalance()
clearedChannel.send(ledger.getClearedTransactions())
}
twig("done handling changed transactions")
}
suspend fun refreshBalance() = withContext(IO) {
if (!balanceChannel.isClosedForSend) {
twig("triggering a balance update because transactions have changed")
balanceChannel.send(wallet.getBalanceInfo())
} else {
twig("WARNING: noticed new transactions but the balance channel was closed for send so ignoring!")
}
}
private fun CoroutineScope.onReady() = launch(CoroutineExceptionHandler(::onCriticalError)) {
twig("Synchronizer Ready. Starting processor!")
processor.onErrorListener = ::onProcessorError
processor.start()
twig("Synchronizer onReady complete. Processor start has exited!")
}
private fun onCriticalError(unused: CoroutineContext, error: Throwable) {
twig("********")
twig("******** ERROR: $error")
if (error.cause != null) twig("******** caused by ${error.cause}")
if (error.cause?.cause != null) twig("******** caused by ${error.cause?.cause}")
twig("********")
onCriticalErrorHandler?.invoke(error)
}
private fun onFailedSend(error: Throwable): Boolean {
twig("ERROR while submitting transaction: $error")
return onSubmissionErrorHandler?.invoke(error)?.also {
if (it) twig("submission error handler signaled that we should try again!")
} == true
}
private fun onProcessorError(error: Throwable): Boolean {
twig("ERROR while processing data: $error")
return onProcessorErrorHandler?.invoke(error)?.also {
if (it) twig("processor error handler signaled that we should try again!")
} == true
}
//
// Channels
//
override fun balances(): ReceiveChannel<Wallet.WalletBalance> {
return balanceChannel.openSubscription()
}
override fun progress(): ReceiveChannel<Int> {
return progressChannel.openSubscription()
}
override fun pendingTransactions(): ReceiveChannel<List<PendingTransaction>> {
return pendingChannel.openSubscription()
}
override fun clearedTransactions(): ReceiveChannel<List<ClearedTransaction>> {
return clearedChannel.openSubscription()
}
override fun lastPending(): List<PendingTransaction> {
return if (pendingChannel.isClosedForSend) listOf() else pendingChannel.value
}
override fun lastCleared(): List<ClearedTransaction> {
return if (clearedChannel.isClosedForSend) listOf() else clearedChannel.value
}
override fun lastBalance(): Wallet.WalletBalance {
return balanceChannel.value
}
//
// Send / Receive
//
override fun cancelSend(transaction: SentTransaction): Boolean {
// not implemented
throw NotImplementedError("Cancellation is not yet implemented " +
"but should be pretty straight forward, using th PersistentTransactionManager")
}
override suspend fun getAddress(accountId: Int): String = withContext(IO) { wallet.getAddress() }
override suspend fun sendToAddress(
zatoshi: Long,
toAddress: String,
memo: String,
fromAccountId: Int
): PendingTransaction = withContext(IO) {
sender.sendToAddress(encoder, zatoshi, toAddress, memo, fromAccountId)
}
}

View File

@ -1,18 +1,23 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.entity.ClearedTransaction
import cash.z.wallet.sdk.entity.PendingTransaction
import cash.z.wallet.sdk.entity.SentTransaction
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.ReceiveChannel
/**
* Primary interface for interacting with the SDK. Defines the contract that specific implementations like
* [MockSynchronizer] and [SdkSynchronizer] fulfill. Given the language-level support for coroutines, we favor their use
* in the SDK and incorporate that choice into this contract.
* [MockSynchronizer] and [StableSynchronizer] fulfill. Given the language-level support for coroutines, we favor their
* use in the SDK and incorporate that choice into this contract.
*/
interface Synchronizer {
/* Lifecycle */
//
// Lifecycle
//
/**
* Starts this synchronizer within the given scope.
*
@ -22,26 +27,18 @@ interface Synchronizer {
fun start(parentScope: CoroutineScope): Synchronizer
/**
* Stop this synchronizer.
* Stop this synchronizer. Implementations should ensure that calling this method cancels all jobs that were created
* by this instance.
*/
fun stop()
/* Channels */
// NOTE: each of these are expected to be a broadcast channel, such that [receive] always returns the latest value
//
// Channels
//
/**
* A stream of all the active transactions.
*/
fun activeTransactions(): ReceiveChannel<Map<ActiveTransaction, TransactionState>>
/**
* A stream of all the wallet transactions.
*/
fun allTransactions(): ReceiveChannel<List<WalletTransaction>>
/**
* A stream of balance values.
* A stream of balance values, separately reflecting both the available and total balance.
*/
fun balances(): ReceiveChannel<Wallet.WalletBalance>
@ -52,54 +49,70 @@ interface Synchronizer {
*/
fun progress(): ReceiveChannel<Int>
/* Status */
/**
* A stream of all the outbound pending transaction that have been sent but are awaiting confirmations.
*/
fun pendingTransactions(): ReceiveChannel<List<PendingTransaction>>
/**
* A flag to indicate that this Synchronizer is significantly out of sync with it's server. Typically, this means
* that the balance and other data cannot be completely trusted because a significant amount of data has not been
* processed. This is intended for showing progress indicators when the user returns to the app after having not
* used it for days. Typically, this means minor sync issues should be ignored and this should be leveraged in order
* to alert a user that the balance information is stale.
*
* @return true when the local data is significantly out of sync with the remote server and the app data is stale.
* A stream of all the transactions that are on the blockchain. Implementations should consider only returning a
* subset like the most recent 100 transactions, perhaps through paging the underlying database.
*/
suspend fun isStale(): Boolean
fun clearedTransactions(): ReceiveChannel<List<ClearedTransaction>>
/**
* A flag to indicate that this is the first run of this Synchronizer on this device. This is useful for knowing
* whether to initialize databases or other required resources, as well as whether to show walk-throughs.
*
* @return true when this is the first run. Implementations can set criteria for that but typically it will be when
* the database needs to be initialized.
* Holds the most recent value that was transmitted through the [pendingTransactions] channel. Typically, if the
* underlying channel is a BroadcastChannel (and it should be),then this value is simply [pendingChannel.value]
*/
suspend fun isFirstRun(): Boolean
fun lastPending(): List<PendingTransaction>
/**
* Gets or sets a global error listener. This is a useful hook for handling unexpected critical errors.
*
* @return true when the error has been handled and the Synchronizer should continue. False when the error is
* unrecoverable and the Synchronizer should [stop].
* Holds the most recent value that was transmitted through the [clearedTransactions] channel. Typically, if the
* underlying channel is a BroadcastChannel (and it should be), then this value is simply [clearedChannel.value]
*/
var onSynchronizerErrorListener: ((Throwable?) -> Boolean)?
fun lastCleared(): List<ClearedTransaction>
/**
* Holds the most recent value that was transmitted through the [balances] channel. Typically, if the
* underlying channel is a BroadcastChannel (and it should be), then this value is simply [balanceChannel.value]
*/
fun lastBalance(): Wallet.WalletBalance
/* Operations */
//
// Status
//
/**
* A flag indicating whether this Synchronizer is connected to its lightwalletd server. When false, a UI element
* may want to turn red.
*/
val isConnected: Boolean
/**
* A flag indicating whether this Synchronizer is actively downloading compact blocks. When true, a UI element
* may want to turn yellow.
*/
val isSyncing: Boolean
/**
* A flag indicating whether this Synchronizer is actively decrypting compact blocks, searching for transactions.
* When true, a UI element may want to turn yellow.
*/
val isScanning: Boolean
//
// Operations
//
/**
* Gets the address for the given account.
*
* @param accountId the optional accountId whose address is of interest. By default, the first account is used.
*/
fun getAddress(accountId: Int = 0): String
/**
* Gets the available balance for the given account. In most cases, the stream of balances provided by [balances]
* should be used instead of this function.
*
* @param accountId the optional accountId whose balance is of interest. By default, the first account is used.
*/
fun getAvailableBalance(accountId: Int = 0): Long
suspend fun getAddress(accountId: Int = 0): String
/**
* Sends zatoshi.
@ -109,7 +122,12 @@ interface Synchronizer {
* @param memo the optional memo to include as part of the transaction.
* @param fromAccountId the optional account id to use. By default, the first account is used.
*/
suspend fun sendToAddress(zatoshi: Long, toAddress: String, memo: String = "", fromAccountId: Int = 0)
suspend fun sendToAddress(
zatoshi: Long,
toAddress: String,
memo: String = "",
fromAccountId: Int = 0
): PendingTransaction
/**
* Attempts to cancel a previously sent transaction. Typically, cancellation is only an option if the transaction
@ -118,5 +136,36 @@ interface Synchronizer {
* @param transaction the transaction to cancel.
* @return true when the cancellation request was successful. False when it is too late to cancel.
*/
fun cancelSend(transaction: ActiveSendTransaction): Boolean
fun cancelSend(transaction: SentTransaction): Boolean
//
// Error Handling
//
/**
* Gets or sets a global error handler. This is a useful hook for handling unexpected critical errors.
*
* @return true when the error has been handled and the Synchronizer should attempt to continue. False when the
* error is unrecoverable and the Synchronizer should [stop].
*/
var onCriticalErrorHandler: ((Throwable?) -> Boolean)?
/**
* An error handler for exceptions during processing. For instance, a block might be missing or a reorg may get
* mishandled or the database may get corrupted.
*
* @return true when the error has been handled and the processor should attempt to continue. False when the
* error is unrecoverable and the processor should [stop].
*/
var onProcessorErrorHandler: ((Throwable?) -> Boolean)?
/**
* An error handler for exceptions while submitting transactions to lightwalletd. For instance, a transaction may
* get rejected because it would be a double-spend or the user might lose their cellphone signal.
*
* @return true when the error has been handled and the sender should attempt to resend. False when the
* error is unrecoverable and the sender should [stop].
*/
var onSubmissionErrorHandler: ((Throwable?) -> Boolean)?
}

View File

@ -0,0 +1,10 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.entity.EncodedTransaction
interface TransactionEncoder {
/**
* Creates a signed transaction
*/
suspend fun create(zatoshi: Long, toAddress: String, memo: String = ""): EncodedTransaction
}

View File

@ -0,0 +1,22 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.service.LightWalletService
/**
* Manage transactions with the main purpose of reporting which ones are still pending, particularly after failed
* attempts or dropped connectivity. The intent is to help see transactions through to completion.
*/
interface TransactionManager {
fun start()
fun stop()
suspend fun manageCreation(encoder: TransactionEncoder, zatoshiValue: Long, toAddress: String, memo: String, currentHeight: Int): SignedTransaction
suspend fun manageSubmission(service: LightWalletService, pendingTransaction: SignedTransaction)
suspend fun getAll(): List<SignedTransaction>
}
interface SignedTransaction {
val raw: ByteArray
}
interface TransactionError {
val message: String
}

View File

@ -1,16 +1,14 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.entity.ClearedTransaction
import cash.z.wallet.sdk.entity.Transaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.ReceiveChannel
interface TransactionRepository {
fun start(parentScope: CoroutineScope)
fun stop()
fun allTransactions(): ReceiveChannel<List<WalletTransaction>>
fun lastScannedHeight(): Int
fun isInitialized(): Boolean
suspend fun findTransactionById(txId: Long): Transaction?
suspend fun findTransactionByRawId(rawTransactionId: ByteArray): Transaction?
suspend fun deleteTransactionById(txId: Long)
suspend fun getClearedTransactions(): List<ClearedTransaction>
suspend fun monitorChanges(listener: () -> Unit)
}

View File

@ -0,0 +1,19 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.entity.PendingTransaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.SendChannel
interface TransactionSender {
fun start(scope: CoroutineScope)
fun stop()
fun notifyOnChange(channel: SendChannel<List<PendingTransaction>>)
/** only necessary when there is a long delay between starting a transaction and beginning to create it. Like when sweeping a wallet that first needs to be scanned. */
suspend fun prepareTransaction(amount: Long, address: String, memo: String): PendingTransaction?
suspend fun sendPreparedTransaction(encoder: TransactionEncoder, tx: PendingTransaction): PendingTransaction
suspend fun cleanupPreparedTransaction(tx: PendingTransaction)
suspend fun sendToAddress(encoder: TransactionEncoder, zatoshi: Long, toAddress: String, memo: String = "", fromAccountId: Int = 0): PendingTransaction
suspend fun cancel(existingTransaction: PendingTransaction): Unit?
var onSubmissionError: ((Throwable) -> Boolean)?
}

View File

@ -1,6 +1,7 @@
package cash.z.wallet.sdk.data
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CopyOnWriteArraySet
internal typealias Leaf = String
@ -31,6 +32,11 @@ interface Twig {
* Clip a leaf from the bush. Clipped leaves no longer appear in logs.
*/
fun clip(leaf: Leaf) = Bush.leaves.remove(leaf)
/**
* Clip all leaves from the bush.
*/
fun prune() = Bush.leaves.clear()
}
}
@ -45,7 +51,7 @@ interface Twig {
*/
object Bush {
var trunk: Twig = SilentTwig()
val leaves: MutableList<Leaf> = CopyOnWriteArrayList<Leaf>()
val leaves: MutableSet<Leaf> = CopyOnWriteArraySet<Leaf>()
}
/**
@ -54,7 +60,12 @@ object Bush {
inline fun twig(message: String) = Bush.trunk.twig(message)
/**
* Times a tiny log task. Execute the block of code with some twigging around the outside.
* Times a tiny log.
*/
inline fun <R> twig(logMessage: String, block: () -> R): R = Bush.trunk.twig(logMessage, block)
/**
* Meticulously times a tiny task.
*/
inline fun <R> twigTask(logMessage: String, block: () -> R): R = Bush.trunk.twigTask(logMessage, block)
@ -98,6 +109,17 @@ open class CompositeTwig(private val twigBundle: MutableList<Twig>) : Twig {
}
}
/**
* Times a tiny log. Execute the block of code on the clock.
*/
inline fun <R> Twig.twig(logMessage: String, block: () -> R): R {
val start = System.currentTimeMillis()
val result = block()
val elapsed = (System.currentTimeMillis() - start)
twig("$logMessage | ${elapsed}ms")
return result
}
/**
* 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.
@ -107,10 +129,11 @@ open class CompositeTwig(private val twigBundle: MutableList<Twig>) : Twig {
* (otherwise the function and its "block" param would have to suspend)
*/
inline fun <R> Twig.twigTask(logMessage: String, block: () -> R): R {
val start = System.nanoTime()
twig("$logMessage - started | on thread ${Thread.currentThread().name})")
val start = System.nanoTime()
val result = block()
twig("$logMessage - completed | in ${System.nanoTime() - start}ms" +
val elapsed = ((System.nanoTime() - start)/1e6)
twig("$logMessage - completed | in $elapsed ms" +
" on thread ${Thread.currentThread().name}")
return result
}

View File

@ -0,0 +1,28 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.entity.EncodedTransaction
import cash.z.wallet.sdk.exception.TransactionNotEncodedException
import cash.z.wallet.sdk.exception.TransactionNotFoundException
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
class WalletTransactionEncoder(
private val wallet: Wallet,
private val repository: TransactionRepository
) : TransactionEncoder {
/**
* Creates a transaction, throwing an exception whenever things are missing. When the provided wallet implementation
* doesn't throw an exception, we wrap the issue into a descriptive exception ourselves (rather than using
* double-bangs for things).
*/
override suspend fun create(zatoshi: Long, toAddress: String, memo: String): EncodedTransaction = withContext(IO) {
val transactionId = wallet.createRawSendTransaction(zatoshi, toAddress, memo)
val transaction = repository.findTransactionById(transactionId)
?: throw TransactionNotFoundException(transactionId)
EncodedTransaction(transaction.transactionId, transaction.raw
?: throw TransactionNotEncodedException(transactionId)
)
}
}

View File

@ -1,10 +1,13 @@
package cash.z.wallet.sdk.db
import androidx.room.Database
import androidx.room.RoomDatabase
import cash.z.wallet.sdk.dao.CompactBlockDao
import androidx.room.*
import cash.z.wallet.sdk.entity.CompactBlock
//
// Database
//
@Database(
entities = [
CompactBlock::class],
@ -13,4 +16,24 @@ import cash.z.wallet.sdk.entity.CompactBlock
)
abstract class CompactBlockDb : RoomDatabase() {
abstract fun complactBlockDao(): CompactBlockDao
}
//
// Data Access Objects
//
@Dao
interface CompactBlockDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(block: CompactBlock)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(block: List<CompactBlock>)
@Query("DELETE FROM compactblocks WHERE height >= :height")
fun rewindTo(height: Int)
@Query("SELECT MAX(height) FROM compactblocks")
fun latestBlockHeight(): Int
}

View File

@ -1,23 +1,127 @@
package cash.z.wallet.sdk.db
import androidx.room.Database
import androidx.room.RoomDatabase
import cash.z.wallet.sdk.dao.BlockDao
import cash.z.wallet.sdk.dao.TransactionDao
import androidx.room.*
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.entity.Transaction
//
// Database
//
@Database(
entities = [
Transaction::class,
Block::class,
Note::class,
Received::class,
Account::class,
Sent::class
],
version = 2,
version = 3,
exportSchema = false
)
abstract class DerivedDataDb : RoomDatabase() {
abstract fun transactionDao(): TransactionDao
abstract fun blockDao(): BlockDao
abstract fun receivedDao(): ReceivedDao
abstract fun sentDao(): SentDao
}
//
// Data Access Objects
//
@Dao
interface BlockDao {
@Query("SELECT COUNT(height) FROM blocks")
fun count(): Int
@Query("SELECT MAX(height) FROM blocks")
fun lastScannedHeight(): Int
}
@Dao
interface ReceivedDao {
@Query("SELECT COUNT(tx) FROM received_notes")
fun count(): Int
}
@Dao
interface SentDao {
@Query("SELECT COUNT(tx) FROM sent_notes")
fun count(): Int
}
@Dao
interface TransactionDao {
@Query("SELECT COUNT(id_tx) FROM transactions")
fun count(): Int
@Query("SELECT COUNT(block) FROM transactions WHERE block IS NULL")
fun countUnmined(): Int
@Query("SELECT * FROM transactions WHERE id_tx = :id")
fun findById(id: Long): Transaction?
@Query("SELECT * FROM transactions WHERE txid = :rawTransactionId LIMIT 1")
fun findByRawId(rawTransactionId: ByteArray): Transaction?
@Delete
fun delete(transaction: Transaction)
@Query("DELETE FROM transactions WHERE id_tx = :id")
fun deleteById(id: Long)
/**
* Query sent transactions that have been mined, sorted so the newest data is at the top.
*/
@Query("""
SELECT transactions.id_tx AS id,
transactions.block AS minedHeight,
transactions.tx_index AS transactionIndex,
transactions.txid AS rawTransactionId,
transactions.expiry_height AS expiryHeight,
transactions.raw AS raw,
sent_notes.address AS toAddress,
sent_notes.value AS value,
sent_notes.memo AS memo,
sent_notes.id_note AS noteId,
blocks.time AS blockTimeInSeconds
FROM transactions
LEFT JOIN sent_notes
ON transactions.id_tx = sent_notes.tx
LEFT JOIN blocks
ON transactions.block = blocks.height
WHERE transactions.raw IS NOT NULL
AND minedheight > 0
ORDER BY block IS NOT NULL, height DESC, time DESC, txid DESC
LIMIT :limit
""")
fun getSentTransactions(limit: Int = Int.MAX_VALUE): List<SentTransaction>
/**
* Query transactions, aggregating information on send/receive, sorted carefully so the newest data is at the top
* and the oldest transactions are at the bottom.
*/
@Query("""
SELECT transactions.id_tx AS id,
transactions.block AS minedHeight,
transactions.tx_index AS transactionIndex,
transactions.txid AS rawTransactionId,
received_notes.value AS value,
received_notes.memo AS memo,
received_notes.id_note AS noteId,
blocks.time AS blockTimeInSeconds
FROM transactions
LEFT JOIN received_notes
ON transactions.id_tx = received_notes.tx
LEFT JOIN blocks
ON transactions.block = blocks.height
WHERE received_notes.is_change != 1
ORDER BY minedheight DESC, blocktimeinseconds DESC, id DESC
LIMIT :limit
""")
fun getReceivedTransactions(limit: Int = Int.MAX_VALUE): List<ReceivedTransaction>
}

View File

@ -0,0 +1,39 @@
package cash.z.wallet.sdk.db
import androidx.room.*
import cash.z.wallet.sdk.entity.PendingTransaction
//
// Database
//
@Database(
entities = [
PendingTransaction::class
],
version = 1,
exportSchema = false
)
abstract class PendingTransactionDb : RoomDatabase() {
abstract fun pendingTransactionDao(): PendingTransactionDao
}
//
// Data Access Objects
//
@Dao
interface PendingTransactionDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(transaction: PendingTransaction): Long
@Delete
fun delete(transaction: PendingTransaction)
@Query("SELECT * from pending_transactions ORDER BY createTime")
fun getAll(): List<PendingTransaction>
}

View File

@ -27,7 +27,7 @@ import androidx.room.ForeignKey
onDelete = ForeignKey.CASCADE
)]
)
data class Note(
data class Received(
@ColumnInfo(name = "id_note")
val id: Int = 0,
@ -66,7 +66,7 @@ data class Note(
override fun equals(other: Any?): Boolean {
if (this === other) return true
return (other is Note)
return (other is Received)
&& id == other.id
&& transactionId == other.transactionId
&& outputIndex == other.outputIndex

View File

@ -1,48 +0,0 @@
package cash.z.wallet.sdk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.jetbrains.annotations.NotNull
@Entity(
primaryKeys = ["id_tx"], tableName = "transactions",
foreignKeys = [ForeignKey(
entity = Block::class,
parentColumns = ["height"],
childColumns = ["block"],
onDelete = ForeignKey.CASCADE
)]
)
data class Transaction(
@ColumnInfo(name = "id_tx")
val id: Long,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB, name = "txid")
@NotNull
val transactionId: ByteArray,
val block: Int,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
val raw: ByteArray?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
return (other is Transaction)
&& id == other.id
&& transactionId.contentEquals(other.transactionId)
&& block == other.block
&& ((raw == null && other.raw == null) || (raw != null && other.raw != null && raw.contentEquals(other.raw)))
}
override fun hashCode(): Int {
var result = id.toInt()
result = 31 * result + transactionId.contentHashCode()
result = 31 * result + block
result = 31 * result + (raw?.contentHashCode() ?: 0)
return result
}
}

View File

@ -0,0 +1,309 @@
package cash.z.wallet.sdk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import cash.z.wallet.sdk.data.SignedTransaction
import org.jetbrains.annotations.NotNull
//
// Entities
//
@Entity(
primaryKeys = ["id_tx"], tableName = "transactions",
foreignKeys = [ForeignKey(
entity = Block::class,
parentColumns = ["height"],
childColumns = ["block"],
onDelete = ForeignKey.CASCADE
)]
)
data class Transaction(
@ColumnInfo(name = "id_tx")
val id: Long,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB, name = "txid")
@NotNull
val transactionId: ByteArray,
@ColumnInfo(name = "tx_index")
val transactionIndex: Int,
@ColumnInfo(name = "expiry_height")
val expiryHeight: Int,
@ColumnInfo(name = "block")
val minedHeight: Int,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
val raw: ByteArray?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Transaction) return false
if (id != other.id) return false
if (!transactionId.contentEquals(other.transactionId)) return false
if (transactionIndex != other.transactionIndex) return false
if (expiryHeight != other.expiryHeight) return false
if (minedHeight != other.minedHeight) return false
if (raw != null) {
if (other.raw == null) return false
if (!raw.contentEquals(other.raw)) return false
} else if (other.raw != null) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + transactionId.contentHashCode()
result = 31 * result + transactionIndex
result = 31 * result + expiryHeight
result = 31 * result + minedHeight
result = 31 * result + (raw?.contentHashCode() ?: 0)
return result
}
}
@Entity(tableName = "pending_transactions")
data class PendingTransaction(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val toAddress: String = "",
val value: Long = -1,
val memo: String? = null,
val minedHeight: Int = -1,
val expiryHeight: Int = -1,
val encodeAttempts: Int = -1,
val submitAttempts: Int = -1,
val errorMessage: String? = null,
val errorCode: Int? = null,
val createTime: Long = System.currentTimeMillis(),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
override val raw: ByteArray = ByteArray(0),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
val rawTransactionId: ByteArray? = null
) : SignedTransaction {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PendingTransaction) return false
if (id != other.id) return false
if (toAddress != other.toAddress) return false
if (value != other.value) return false
if (memo != other.memo) return false
if (minedHeight != other.minedHeight) return false
if (expiryHeight != other.expiryHeight) return false
if (encodeAttempts != other.encodeAttempts) return false
if (submitAttempts != other.submitAttempts) return false
if (errorMessage != other.errorMessage) return false
if (errorCode != other.errorCode) return false
if (createTime != other.createTime) return false
if (!raw.contentEquals(other.raw)) return false
if (rawTransactionId != null) {
if (other.rawTransactionId == null) return false
if (!rawTransactionId.contentEquals(other.rawTransactionId)) return false
} else if (other.rawTransactionId != null) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + toAddress.hashCode()
result = 31 * result + value.hashCode()
result = 31 * result + memo.hashCode()
result = 31 * result + minedHeight
result = 31 * result + expiryHeight
result = 31 * result + encodeAttempts
result = 31 * result + submitAttempts
result = 31 * result + (errorMessage?.hashCode() ?: 0)
result = 31 * result + (errorCode ?: 0)
result = 31 * result + createTime.hashCode()
result = 31 * result + raw.contentHashCode()
result = 31 * result + (rawTransactionId?.contentHashCode() ?: 0)
return result
}
}
//
// Query Objects
//
/**
* Parent type for transactions that have been mined. This is useful for putting all transactions in one list for things
* like history. A mined tx should have all properties, except possibly a memo.
*/
interface ClearedTransaction {
val id: Long
val value: Long
// val memo: String? --> we don't yet have a good way of privately retrieving incoming memos so let's make that clear
val noteId: Long
val minedHeight: Int
val blockTimeInSeconds: Long
val transactionIndex: Int
val rawTransactionId: ByteArray
}
/**
* A mined, inbound shielded transaction. Since this is a [ClearedTransaction], it represents data on the blockchain.
*/
data class ReceivedTransaction(
override val id: Long = 0L,
override val value: Long = 0L,
// override val memo: String? = null, --> for now we don't have a good way of privately retrieving incoming memos so let's make that clear by omitting this property
override val noteId: Long = 0L,
override val blockTimeInSeconds: Long = 0L,
override val minedHeight: Int = -1,
override val transactionIndex: Int,
override val rawTransactionId: ByteArray = ByteArray(0)
) : ClearedTransaction {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ReceivedTransaction) return false
if (id != other.id) return false
if (value != other.value) return false
if (noteId != other.noteId) return false
if (blockTimeInSeconds != other.blockTimeInSeconds) return false
if (minedHeight != other.minedHeight) return false
if (transactionIndex != other.transactionIndex) return false
if (!rawTransactionId.contentEquals(other.rawTransactionId)) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + value.hashCode()
result = 31 * result + noteId.hashCode()
result = 31 * result + blockTimeInSeconds.hashCode()
result = 31 * result + minedHeight
result = 31 * result + transactionIndex
result = 31 * result + rawTransactionId.contentHashCode()
return result
}
}
/**
* A mined, outbound shielded transaction. Since this is a [ClearedTransaction], it represents data on the blockchain.
*/
data class SentTransaction(
override val id: Long = 0L,
override val value: Long = 0L,
override val noteId: Long = 0L,
override val blockTimeInSeconds: Long = 0L,
override val minedHeight: Int = -1,
override val transactionIndex: Int,
override val rawTransactionId: ByteArray = ByteArray(0),
// sent transactions have memos because we create them and don't have to worry about P.I.R.
val memo: String? = null,
val toAddress: String = "",
val expiryHeight: Int = -1,
override val raw: ByteArray = ByteArray(0)
) : ClearedTransaction, SignedTransaction {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SentTransaction) return false
if (id != other.id) return false
if (value != other.value) return false
if (noteId != other.noteId) return false
if (blockTimeInSeconds != other.blockTimeInSeconds) return false
if (minedHeight != other.minedHeight) return false
if (transactionIndex != other.transactionIndex) return false
if (!rawTransactionId.contentEquals(other.rawTransactionId)) return false
if (memo != other.memo) return false
if (toAddress != other.toAddress) return false
if (expiryHeight != other.expiryHeight) return false
if (!raw.contentEquals(other.raw)) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + value.hashCode()
result = 31 * result + noteId.hashCode()
result = 31 * result + blockTimeInSeconds.hashCode()
result = 31 * result + minedHeight
result = 31 * result + transactionIndex
result = 31 * result + rawTransactionId.contentHashCode()
result = 31 * result + (memo?.hashCode() ?: 0)
result = 31 * result + toAddress.hashCode()
result = 31 * result + expiryHeight
result = 31 * result + raw.contentHashCode()
return result
}
}
data class EncodedTransaction(val txId: ByteArray, val raw: ByteArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is EncodedTransaction) return false
if (!txId.contentEquals(other.txId)) return false
if (!raw.contentEquals(other.raw)) return false
return true
}
override fun hashCode(): Int {
var result = txId.contentHashCode()
result = 31 * result + raw.contentHashCode()
return result
}
}
//
// Extension-oriented design
//
fun PendingTransaction.isSameTxId(other: ClearedTransaction): Boolean {
return rawTransactionId != null && other.rawTransactionId != null && rawTransactionId.contentEquals(other.rawTransactionId)
}
fun PendingTransaction.isSameTxId(other: PendingTransaction): Boolean {
return rawTransactionId != null && other.rawTransactionId != null && rawTransactionId.contentEquals(other.rawTransactionId)
}
fun PendingTransaction.isCreating(): Boolean {
return raw.isEmpty() && submitAttempts <= 0 && !isFailedSubmit() && !isFailedEncoding()
}
fun PendingTransaction.isFailedEncoding(): Boolean {
return raw.isEmpty() && encodeAttempts > 0
}
fun PendingTransaction.isFailedSubmit(): Boolean {
return errorMessage != null || (errorCode != null && errorCode < 0)
}
fun PendingTransaction.isFailure(): Boolean {
return isFailedEncoding() || isFailedSubmit()
}
fun PendingTransaction.isMined(): Boolean {
return minedHeight > 0
}
fun PendingTransaction.isSubmitted(): Boolean {
return submitAttempts > 0
}
fun PendingTransaction.isPending(currentHeight: Int = -1): Boolean {
// not mined and not expired and successfully created
return !isSubmitSuccess() && minedHeight == -1 && (expiryHeight == -1 || expiryHeight > currentHeight) && raw != null
}
fun PendingTransaction.isSubmitSuccess(): Boolean {
return submitAttempts > 0 && (errorCode != null && errorCode >= 0) && errorMessage == null
}

View File

@ -1,5 +1,6 @@
package cash.z.wallet.sdk.exception
import java.lang.Exception
import java.lang.RuntimeException
/**
@ -25,6 +26,12 @@ sealed class SynchronizerException(message: String, cause: Throwable? = null) :
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 `rustBackend.initDataDb(path)`")
open class ConfigurationException(message: String, cause: Throwable?) : CompactBlockProcessorException(message, cause)
class FileInsteadOfPath(fileName: String) : ConfigurationException("Invalid Path: the given path appears to be a" +
" file name instead of a path: $fileName. The RustBackend expects the absolutePath to the database rather" +
" than just the database filename because Rust does not access the app Context." +
" So pass in context.getDatabasePath(dbFileName).absolutePath instead of just dbFileName alone.", null)
class FailedReorgRepair(message: String) : CompactBlockProcessorException(message)
}
sealed class CompactBlockStreamException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
@ -45,4 +52,17 @@ sealed class WalletException(message: String, cause: Throwable? = null) : Runtim
"Failed to parse file $directory/$file verify that it is formatted as #####.json, " +
"where the first portion is an Int representing the height of the tree contained in the file"
)
}
class AlreadyInitializedException(cause: Throwable) : WalletException("Failed to initialize the blocks table" +
" because it already exists.", cause)
class FalseStart(cause: Throwable?) : WalletException("Failed to initialize wallet due to: $cause", cause)
}
class TransactionNotFoundException(transactionId: Long) : RuntimeException("Unable to find transactionId " +
"$transactionId in the repository. This means the wallet created a transaction and then returned a row ID " +
"that does not actually exist. This is a scenario where the wallet should have thrown an exception but failed " +
"to do so.")
class TransactionNotEncodedException(transactionId: Long) : RuntimeException("The transaction returned by the wallet," +
" with id $transactionId, does not have any raw data. This is a scenario where the wallet should have thrown" +
" an exception but failed to do so.")

View File

@ -11,7 +11,7 @@ import java.util.*
//TODO: provide a dynamic way to configure this globally for the SDK
// For now, just make these vars so at least they could be modified in one place
object Conversions {
var ONE_ZEC_IN_ZATOSHI = BigDecimal(100_000_000.0, MathContext.DECIMAL128)
var ONE_ZEC_IN_ZATOSHI = BigDecimal(ZATOSHI_PER_ZEC, MathContext.DECIMAL128)
var ZEC_FORMATTER = NumberFormat.getInstance(Locale.getDefault()).apply {
roundingMode = RoundingMode.HALF_EVEN
maximumFractionDigits = 6

View File

@ -0,0 +1,70 @@
package cash.z.wallet.sdk.ext
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.provider.Settings
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
@Deprecated(message = InsecureWarning.message)
class SampleSpendingKeyProvider(private val seedValue: String) : ReadWriteProperty<Any?, String> {
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
}
override fun getValue(thisRef: Any?, property: KProperty<*>): String {
// dynamically generating keys, based on seed is out of scope for this sample
if (seedValue != "dummyseed") throw IllegalStateException("This sample provider only supports the dummy seed")
return "secret-extended-key-test1q0f0urnmqqqqpqxlree5urprcmg9pdgvr2c88qhm862etv65eu84r9zwannpz4g88299xyhv7wf9" +
"xkecag653jlwwwyxrymfraqsnz8qfgds70qjammscxxyl7s7p9xz9w906epdpy8ztsjd7ez7phcd5vj7syx68sjskqs8j9lef2uu" +
"acghsh8puuvsy9u25pfvcdznta33qe6xh5lrlnhdkgymnpdug4jm6tpf803cad6tqa9c0ewq9l03fqxatevm97jmuv8u0ccxjews5"
}
}
@Deprecated(message = InsecureWarning.message)
class SampleSeedProvider(val seedValue: String) : ReadOnlyProperty<Any?, ByteArray> {
override fun getValue(thisRef: Any?, property: KProperty<*>): ByteArray {
return seedValue.toByteArray()
}
}
@Deprecated(message = InsecureWarning.message)
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
}
}
/**
* This is intentionally insecure. Wallet makers have told us storing keys is their specialty so we don't put a lot of
* energy here. A true implementation would create a key using user interaction, perhaps with a password they know that
* is never stored, along with requiring user authentication for key use (i.e. fingerprint/PIN/pattern/etc). From there,
* one of these approaches might be helpful to store the key securely:
*
* https://developer.android.com/training/articles/keystore.html
* https://github.com/scottyab/AESCrypt-Android/blob/master/aescrypt/src/main/java/com/scottyab/aescrypt/AESCrypt.java
* https://github.com/iamMehedi/Secured-Preference-Store
*/
@SuppressLint("HardwareIds")
@Deprecated(message = InsecureWarning.message)
class SeedGenerator {
companion object {
@Deprecated(message = InsecureWarning.message)
fun getDeviceId(appContext: Context): String {
val id =
Build.FINGERPRINT + Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID)
return id.replace("\\W".toRegex(), "_")
}
}
}
internal object InsecureWarning {
const val message = "Do not use this because it is insecure and only intended for test code and samples. " +
"Instead, use the Android Keystore system or a 3rd party library that leverages it."
}

View File

@ -1,10 +1,55 @@
package cash.z.wallet.sdk.ext
import android.content.Context
import cash.z.wallet.sdk.data.twig
import cash.z.wallet.sdk.rpc.Service
import kotlinx.coroutines.delay
import java.io.File
import kotlin.random.Random
inline fun Int.toBlockHeight(): Service.BlockID = Service.BlockID.newBuilder().setHeight(this.toLong()).build()
inline fun IntRange.toBlockRange(): Service.BlockRange =
Service.BlockRange.newBuilder()
.setStart(this.first.toBlockHeight())
.setEnd(this.last.toBlockHeight())
.build()
suspend inline fun retryUpTo(retries: Int, initialDelay: Int = 10, block: () -> 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 inline fun retryWithBackoff(noinline onErrorListener: ((Throwable) -> Boolean)? = null, initialDelayMillis: Long = 1000L, maxDelayMillis: Long = DEFAULT_MAX_BACKOFF_INTERVAL, block: () -> Unit) {
var sequence = 0 // count up to the max and then reset to half. So that we don't repeat the max but we also don't repeat too much.
while (true) {
try {
block()
return
} catch (t: Throwable) {
// offer to listener first
if (onErrorListener?.invoke(t) == false) {
throw t
}
sequence++
// I^(1/4)n + jitter
var duration = Math.pow(initialDelayMillis.toDouble(), (sequence.toDouble()/4.0)).toLong() + Random.nextLong(1000L)
if (duration > maxDelayMillis) {
duration = maxDelayMillis - Random.nextLong(1000L) // include jitter but don't exceed max delay
sequence /= 2
}
twig("Failed due to $t retrying in ${duration}ms...")
delay(duration)
}
}
}
internal fun dbExists(appContext: Context, dbFileName: String): Boolean {
return File(appContext.getDatabasePath(dbFileName).absolutePath).exists()
}

View File

@ -0,0 +1,70 @@
package cash.z.wallet.sdk.ext
//
// Constants
//
/**
* Miner's fee in zatoshi.
*/
const val MINERS_FEE_ZATOSHI = 10_000L
/**
* The number of zatoshi that equal 1 ZEC.
*/
const val ZATOSHI_PER_ZEC = 100_000_000L
/**
* The height of the first sapling block. When it comes to shielded transactions, we do not need to consider any blocks
* prior to this height, at all.
*/
const val SAPLING_ACTIVATION_HEIGHT = 280_000
/**
* The theoretical maximum number of blocks in a reorg, due to other bottlenecks in the protocol design.
*/
const val MAX_REORG_SIZE = 100
/**
* The amount of blocks ahead of the current height where new transactions are set to expire. This value is controlled
* by the rust backend but it is helpful to know what it is set to and shdould be kept in sync.
*/
const val EXPIRY_OFFSET = 20
//
// Defaults
//
/**
* Default size of batches of blocks to request from the compact block service.
*/
const val DEFAULT_BATCH_SIZE = 100
/**
* Default amount of time, in milliseconds, to poll for new blocks. Typically, this should be about half the average
* block time.
*/
const val DEFAULT_POLL_INTERVAL = 75_000L
/**
* Default attempts at retrying.
*/
const val DEFAULT_RETRIES = 5
/**
* The default maximum amount of time to wait during retry backoff intervals. Failed loops will never wait longer than
* this before retyring.
*/
const val DEFAULT_MAX_BACKOFF_INTERVAL = 600_000L
/**
* Default number of blocks to rewind when a chain reorg is detected. This should be large enough to recover from the
* reorg but smaller than the theoretical max reorg size of 100.
*/
const val DEFAULT_REWIND_DISTANCE = 10
/**
* The number of blocks to allow before considering our data to be stale. This usually helps with what to do when
* returning from the background and is exposed via the Synchronizer's isStale function.
*/
const val DEFAULT_STALE_TOLERANCE = 10

View File

@ -2,11 +2,11 @@ package cash.z.wallet.sdk.secure
import android.content.Context
import cash.z.wallet.sdk.data.Bush
import cash.z.wallet.sdk.data.CompactBlockProcessor.Companion.SAPLING_ACTIVATION_HEIGHT
import cash.z.wallet.sdk.data.twig
import cash.z.wallet.sdk.data.twigTask
import cash.z.wallet.sdk.exception.RustLayerException
import cash.z.wallet.sdk.exception.WalletException
import cash.z.wallet.sdk.ext.SAPLING_ACTIVATION_HEIGHT
import cash.z.wallet.sdk.ext.masked
import cash.z.wallet.sdk.jni.RustBackendWelding
import cash.z.wallet.sdk.secure.Wallet.WalletBirthday
@ -15,8 +15,6 @@ import com.google.gson.stream.JsonReader
import com.squareup.okhttp.OkHttpClient
import com.squareup.okhttp.Request
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.withContext
import okio.Okio
import java.io.File
@ -44,15 +42,16 @@ class Wallet(
constructor(
context: Context,
rustBackend: RustBackendWelding,
dataDbPath: String,
paramDestinationDir: String,
accountIds: Array<Int> = arrayOf(0),
dataDbName: String,
seedProvider: ReadOnlyProperty<Any?, ByteArray>,
spendingKeyProvider: ReadWriteProperty<Any?, String>
spendingKeyProvider: ReadWriteProperty<Any?, String>,
birthday: WalletBirthday = loadBirthdayFromAssets(context),
paramDestinationDir: String = "${context.cacheDir.absolutePath}/params",
accountIds: Array<Int> = arrayOf(0)
) : this(
birthday = loadBirthdayFromAssets(context),
birthday = birthday,
rustBackend = rustBackend,
dataDbPath = dataDbPath,
dataDbPath = context.getDatabasePath(dataDbName).absolutePath,
paramDestinationDir = paramDestinationDir,
accountIds = accountIds,
seedProvider = seedProvider,
@ -64,11 +63,6 @@ class Wallet(
*/
private var spendingKeyStore by spendingKeyProvider
/**
* Channel where balance info will be emitted.
*/
private val balanceChannel = ConflatedBroadcastChannel<WalletBalance>()
/**
* Initializes the wallet by creating the DataDb and pre-populating it with data corresponding to the birthday for
* this wallet.
@ -76,17 +70,34 @@ class Wallet(
fun initialize(
firstRunStartHeight: Int = SAPLING_ACTIVATION_HEIGHT
): Int {
twig("Initializing wallet for first run")
rustBackend.initDataDb(dataDbPath)
twig("seeding the database with sapling tree at height ${birthday.height}")
rustBackend.initBlocksTable(dataDbPath, birthday.height, birthday.hash, birthday.time, birthday.tree)
// TODO: find a better way to map these exceptions from the Rust side. For now, match error text :(
// store the spendingkey by leveraging the utilities provided during construction
val seed by seedProvider
val accountSpendingKeys = rustBackend.initAccountsTable(dataDbPath, seed, 1)
spendingKeyStore = accountSpendingKeys[0]
try {
rustBackend.initDataDb(dataDbPath)
twig("Initialized wallet for first run into file $dataDbPath")
} catch (e: Throwable) {
throw WalletException.FalseStart(e)
}
return Math.max(firstRunStartHeight, birthday.height)
try {
rustBackend.initBlocksTable(dataDbPath, birthday.height, birthday.hash, birthday.time, birthday.tree)
twig("seeded the database with sapling tree at height ${birthday.height} into file $dataDbPath")
} catch (t: Throwable) {
if (t.message?.contains("is not empty") == true) throw WalletException.AlreadyInitializedException(t)
else throw WalletException.FalseStart(t)
}
try {
// store the spendingkey by leveraging the utilities provided during construction
val seed by seedProvider
val accountSpendingKeys = rustBackend.initAccountsTable(dataDbPath, seed, 1)
spendingKeyStore = accountSpendingKeys[0]
twig("Initialized the accounts table into file $dataDbPath")
return Math.max(firstRunStartHeight, birthday.height)
} catch (e: Throwable) {
throw WalletException.FalseStart(e)
}
}
/**
@ -96,13 +107,6 @@ class Wallet(
return rustBackend.getAddress(dataDbPath, accountId)
}
/**
* Stream of balances.
*/
fun balances(): ReceiveChannel<WalletBalance> {
return balanceChannel.openSubscription()
}
/**
* Return a quick snapshot of the available balance. In most cases, the stream of balances
* provided by [balances] should be used instead of this funciton.
@ -118,14 +122,14 @@ class Wallet(
*
* @param accountId the account to check for balance info.
*/
suspend fun sendBalanceInfo(accountId: Int = accountIds[0]) = withContext(IO) {
suspend fun getBalanceInfo(accountId: Int = accountIds[0]): WalletBalance = withContext(IO) {
twigTask("checking balance info") {
try {
val balanceTotal = rustBackend.getBalance(dataDbPath, accountId)
twig("found total balance of: $balanceTotal")
val balanceAvailable = rustBackend.getVerifiedBalance(dataDbPath, accountId)
twig("found available balance of: $balanceAvailable")
balanceChannel.send(WalletBalance(balanceTotal, balanceAvailable))
WalletBalance(balanceTotal, balanceAvailable)
} catch (t: Throwable) {
twig("failed to get balance due to $t")
throw RustLayerException.BalanceException(t)
@ -145,9 +149,8 @@ class Wallet(
*/
suspend fun createRawSendTransaction(value: Long, toAddress: String, memo: String = "", fromAccountId: Int = accountIds[0]): Long =
withContext(IO) {
var result = -1L
twigTask("creating raw transaction to send $value zatoshi to ${toAddress.masked()}") {
result = runCatching {
twigTask("creating raw transaction to send $value zatoshi to ${toAddress.masked()} with memo $memo") {
try {
ensureParams(paramDestinationDir)
twig("params exist at $paramDestinationDir! attempting to send...")
rustBackend.sendToAddress(
@ -161,10 +164,13 @@ class Wallet(
spendParams = SPEND_PARAM_FILE_NAME.toPath(),
outputParams = OUTPUT_PARAM_FILE_NAME.toPath()
)
}.getOrDefault(result)
} catch (t: Throwable) {
twig("${t.message}")
throw t
}
}.also { result ->
twig("result of sendToAddress: $result")
}
twig("result of sendToAddress: $result")
result
}
/**

View File

@ -0,0 +1,67 @@
package cash.z.wallet.sdk.service
import android.content.Context
import cash.z.wallet.sdk.entity.CompactBlock
import cash.z.wallet.sdk.ext.toBlockHeight
import cash.z.wallet.sdk.rpc.CompactFormats
import cash.z.wallet.sdk.rpc.CompactTxStreamerGrpc
import cash.z.wallet.sdk.rpc.Service
import com.google.protobuf.ByteString
import io.grpc.Channel
import io.grpc.ManagedChannel
import io.grpc.android.AndroidChannelBuilder
import java.util.concurrent.TimeUnit
class LightWalletGrpcService(private val channel: ManagedChannel) : LightWalletService {
constructor(appContext: Context, host: String, port: Int = 9067) : this(
AndroidChannelBuilder
.forAddress(host, port)
.context(appContext)
.usePlaintext()
.build()
)
/* LightWalletService implementation */
override fun getBlockRange(heightRange: IntRange): List<CompactBlock> {
channel.resetConnectBackoff()
return channel.createStub(90L).getBlockRange(heightRange.toBlockRange()).toList()
}
override fun getLatestBlockHeight(): Int {
channel.resetConnectBackoff()
return channel.createStub(10L).getLatestBlock(Service.ChainSpec.newBuilder().build()).height.toInt()
}
override fun submitTransaction(raw: ByteArray): Service.SendResponse {
channel.resetConnectBackoff()
val request = Service.RawTransaction.newBuilder().setData(ByteString.copyFrom(raw)).build()
return channel.createStub().sendTransaction(request)
}
//
// Utilities
//
private fun Channel.createStub(timeoutSec: Long = 60L): CompactTxStreamerGrpc.CompactTxStreamerBlockingStub =
CompactTxStreamerGrpc
.newBlockingStub(this)
.withDeadlineAfter(timeoutSec, TimeUnit.SECONDS)
private fun IntRange.toBlockRange(): Service.BlockRange =
Service.BlockRange.newBuilder()
.setStart(this.first.toBlockHeight())
.setEnd(this.last.toBlockHeight())
.build()
private fun Iterator<CompactFormats.CompactBlock>.toList(): List<CompactBlock> =
mutableListOf<CompactBlock>().apply {
while (hasNext()) {
val compactBlock = next()
this@apply += CompactBlock(compactBlock.height.toInt(), compactBlock.toByteArray())
}
}
}

View File

@ -0,0 +1,28 @@
package cash.z.wallet.sdk.service
import cash.z.wallet.sdk.entity.CompactBlock
import cash.z.wallet.sdk.rpc.Service
/**
* Service for interacting with lightwalletd. Implementers of this service should make blocking calls because
* async concerns are handled at a higher level.
*/
interface LightWalletService {
/**
* Return the given range of blocks.
*
* @param heightRange the inclusive range to fetch. For instance if 1..5 is given, then every block in that range
* will be fetched, including 1 and 5.
*/
fun getBlockRange(heightRange: IntRange): List<CompactBlock>
/**
* Return the latest block height known to the service.
*/
fun getLatestBlockHeight(): Int
/**
* Submit a raw transaction.
*/
fun submitTransaction(transactionRaw: ByteArray): Service.SendResponse
}

View File

@ -0,0 +1,153 @@
package cash.z.wallet.sdk.block
import cash.z.wallet.sdk.data.TransactionRepository
import cash.z.wallet.sdk.data.TroubleshootingTwig
import cash.z.wallet.sdk.data.Twig
import cash.z.wallet.sdk.entity.CompactBlock
import cash.z.wallet.sdk.ext.SAPLING_ACTIVATION_HEIGHT
import cash.z.wallet.sdk.jni.RustBackendWelding
import cash.z.wallet.sdk.service.LightWalletService
import com.nhaarman.mockitokotlin2.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.junit.jupiter.MockitoSettings
import org.mockito.quality.Strictness
@ExtendWith(MockitoExtension::class)
@MockitoSettings(strictness = Strictness.LENIENT)
internal class CompactBlockProcessorTest {
private val frequency = 5L
// Mocks/Spys
@Mock lateinit var rustBackend: RustBackendWelding
lateinit var processor: CompactBlockProcessor
// Test variables
private var latestBlockHeight: Int = 500_000
private var lastDownloadedHeight: Int = SAPLING_ACTIVATION_HEIGHT
private var lastScannedHeight: Int = SAPLING_ACTIVATION_HEIGHT
private var errorBlock: Int = -1
@BeforeEach
fun setUp(
@Mock lightwalletService: LightWalletService,
@Mock compactBlockStore: CompactBlockStore,
@Mock repository: TransactionRepository
) {
Twig.plant(TroubleshootingTwig())
lightwalletService.stub {
onBlocking {
getBlockRange(any())
}.thenAnswer { invocation ->
val range = invocation.arguments[0] as IntRange
range.map { CompactBlock(it, ByteArray(0)) }
}
}
lightwalletService.stub {
onBlocking {
getLatestBlockHeight()
}.thenAnswer { latestBlockHeight }
}
compactBlockStore.stub {
onBlocking {
write(any())
}.thenAnswer { invocation ->
val lastBlockHeight = (invocation.arguments[0] as List<CompactBlock>).last().height
lastDownloadedHeight = lastBlockHeight
Unit
}
}
compactBlockStore.stub {
onBlocking {
getLatestHeight()
}.thenAnswer { lastDownloadedHeight }
}
compactBlockStore.stub {
onBlocking {
rewindTo(any())
}.thenAnswer { invocation ->
lastDownloadedHeight = invocation.arguments[0] as Int
Unit
}
}
repository.stub {
onBlocking {
lastScannedHeight()
}.thenAnswer { lastScannedHeight }
}
val config = ProcessorConfig(retries = 1, blockPollFrequencyMillis = frequency, downloadBatchSize = 50_000)
val downloader = spy(CompactBlockDownloader(lightwalletService, compactBlockStore))
processor = spy(CompactBlockProcessor(config, downloader, repository, rustBackend))
whenever(rustBackend.validateCombinedChain(any(), any())).thenAnswer {
errorBlock
}
whenever(rustBackend.scanBlocks(any(), any())).thenAnswer {
true
}
}
@AfterEach
fun tearDown() {
}
@Test
fun `check for OBOE when downloading`() = runBlocking {
// if the last block downloaded was 350_000, then we already have that block and should start with 350_001
lastDownloadedHeight = 350_000
processBlocks()
verify(processor).downloadNewBlocks(350_001..latestBlockHeight)
}
@Test
fun `chain error rewinds by expected amount`() = runBlocking {
// if the highest block whose prevHash doesn't match happens at block 300_010
errorBlock = 300_010
// then we should rewind the default (10) blocks
val expectedBlock = errorBlock - processor.config.rewindDistance
processBlocks(100L)
verify(processor.downloader, atLeastOnce()).rewindTo(expectedBlock)
verify(rustBackend, atLeastOnce()).rewindToHeight("", expectedBlock)
assertNotNull(processor)
}
@Test
fun `chain error downloads expected number of blocks`() = runBlocking {
// if the highest block whose prevHash doesn't match happens at block 300_010
// and our rewind distance is the default (10), then we want to download exactly ten blocks
errorBlock = 300_010
// plus 1 because the range is inclusive
val expectedRange = (errorBlock - processor.config.rewindDistance + 1)..latestBlockHeight
processBlocks(1500L)
verify(processor, atLeastOnce()).downloadNewBlocks(expectedRange)
}
private fun processBlocks(delayMillis: Long? = null) = runBlocking {
launch { processor.start() }
val progressChannel = processor.progress()
for (i in progressChannel) {
if(i >= 100) {
if(delayMillis != null) delay(delayMillis)
processor.stop()
break
}
}
}
}

View File

@ -1,173 +0,0 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.anyNotNull
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 kotlinx.coroutines.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
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.mockito.ArgumentMatchers.any
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.junit.jupiter.MockitoSettings
import org.mockito.quality.Strictness
import kotlin.system.measureTimeMillis
import org.junit.Rule
import io.grpc.testing.GrpcServerRule
import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport
@ExtendWith(MockitoExtension::class)
@MockitoSettings(strictness = Strictness.LENIENT) // allows us to setup the blockingStub once, with everything, rather than using custom stubs for each test
@EnableRuleMigrationSupport
class CompactBlockDownloaderTest {
lateinit var downloader: CompactBlockStream
lateinit var connection: CompactBlockStream.Connection
val job = Job()
val io = CoroutineScope(Dispatchers.IO + job)
@Rule
var grpcServerRule = GrpcServerRule()
@BeforeEach
fun setUp(@Mock blockingStub: CompactTxStreamerBlockingStub) {
whenever(blockingStub.getLatestBlock(any())).doAnswer {
getLatestBlock()
}
// when asked for a block range, create an array of blocks and return an iterator over them with a slight delay between iterations
whenever(blockingStub.getBlockRange(any())).doAnswer {
val serviceRange = it.arguments[0] as Service.BlockRange
val range = serviceRange.start.height..serviceRange.end.height
val blocks = mutableListOf<CompactFormats.CompactBlock>()
System.err.println("[Mock Connection] creating blocks in range: $range")
for (i in range) {
blocks.add(CompactFormats.CompactBlock.newBuilder().setHeight(i).build())
}
val blockIterator = blocks.iterator()
val delayedIterator = object : Iterator<CompactFormats.CompactBlock> {
override fun hasNext() = blockIterator.hasNext()
override fun next(): CompactFormats.CompactBlock {
Thread.sleep(10L)
return blockIterator.next()
}
}
delayedIterator
}
downloader = CompactBlockStream(grpcServerRule.channel, TroubleshootingTwig())
connection = spy(downloader.connection)
whenever(connection.createStub(anyNotNull())).thenReturn(blockingStub)
}
@AfterEach
fun tearDown() {
downloader.stop()
io.cancel()
}
@Test
fun `mock configuration sanity check`() = runBlocking<Unit> {
assertEquals(getLatestBlock().height, connection.getLatestBlockHeight(), "Unexpected height. Verify that mocks are properly configured.")
}
@Test
fun `downloading missing blocks happens in chunks`() = runBlocking<Unit> {
val start = getLatestBlock().height.toInt() - 31
val downloadCount = connection.downloadMissingBlocks(start, 10) - start
assertEquals(32, downloadCount)
// verify(connection).getLatestBlockHeight()
// verify(connection).loadBlockRange(start..(start + 9)) // a range of 10 block is requested
// verify(connection, times(4)).loadBlockRange(anyNotNull()) // 4 batches are required
}
@Test
fun `channel contains expected blocks`() = runBlocking {
val mailbox = connection.subscribe()
var blockCount = 0
val start = getLatestBlock().height - 31L
io.launch {
connection.downloadMissingBlocks(start.toInt(), 10)
mailbox.cancel() // exits the for loop, below, once downloading is complete
}
for(block in mailbox) {
println("got block with height ${block.height} on thread ${Thread.currentThread().name}")
blockCount++
}
assertEquals(32, blockCount)
}
// lots of logging here because this is more of a sanity test for peace of mind
@Test
fun `streaming yields the latest blocks with proper timing`() = runBlocking {
// just tweak these a bit for sanity rather than making a bunch of tests that would be slow
val pollInterval = BLOCK_INTERVAL_MILLIS/2L
val repetitions = 3
println("${System.currentTimeMillis()} : starting with blockInterval $BLOCK_INTERVAL_MILLIS and pollInterval $pollInterval")
val mailbox = connection.subscribe()
io.launch {
connection.streamBlocks(pollInterval)
}
// sync up with the block interval, first
mailbox.receive()
// now, get a few blocks and measure the expected time
val deltaTime = measureTimeMillis {
repeat(repetitions) {
println("${System.currentTimeMillis()} : checking the mailbox on thread ${Thread.currentThread().name}...")
val mail = mailbox.receive()
println("${System.currentTimeMillis()} : ...got ${mail.height} in the mail! on thread ${Thread.currentThread().name}")
}
}
val totalIntervals = repetitions * BLOCK_INTERVAL_MILLIS
val bounds = (totalIntervals - pollInterval)..(totalIntervals + pollInterval)
println("${System.currentTimeMillis()} : finished in $deltaTime and it was between $bounds")
mailbox.cancel()
assertTrue(bounds.contains(deltaTime), "Blocks received ${if(bounds.first < deltaTime) "slower" else "faster"} than expected. $deltaTime should be in the range of $bounds")
}
@Test
fun `downloader gets missing blocks and then streams`() = runBlocking {
val targetHeight = getLatestBlock().height.toInt() + 3
val initialBlockHeight = targetHeight - 30
println("starting from $initialBlockHeight to $targetHeight")
val mailbox = downloader.start(io, initialBlockHeight, 10, 500L)
// receive from channel until we reach the target height, counting blocks along the way
var firstBlock: CompactFormats.CompactBlock? = null
var blockCount = 0
do {
println("waiting for block number $blockCount...")
val block = mailbox.receive()
println("...received block ${block.height} on thread ${Thread.currentThread().name}")
blockCount++
if (firstBlock == null) firstBlock = block
} while (block.height < targetHeight)
mailbox.cancel()
assertEquals(firstBlock?.height, initialBlockHeight, "Failed to start at block $initialBlockHeight")
assertEquals(targetHeight - initialBlockHeight + 1L, blockCount.toLong(), "Incorrect number of blocks, verify that there are no duplicates in the test output")
}
companion object {
const val BLOCK_INTERVAL_MILLIS = 1000L
private fun getLatestBlock(): Service.BlockID {
// number of intervals that have passed (without rounding...)
val intervalCount = System.currentTimeMillis() / BLOCK_INTERVAL_MILLIS
return intervalCount.toInt().toBlockHeight()
}
}
}

View File

@ -1,6 +1,6 @@
package cash.z.wallet.sdk.data
import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.dao.ClearedTransaction
import kotlinx.coroutines.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
@ -141,8 +141,8 @@ internal class MockSynchronizerTest {
@Test
fun `balance matches transactions without sends`() = runBlocking {
val balances = fastSynchronizer.start(fastSynchronizer).balance()
var transactions = listOf<WalletTransaction>()
val balances = fastSynchronizer.start(fastSynchronizer).balances()
var transactions = listOf<ClearedTransaction>()
while (transactions.count() < 10) {
transactions = fastSynchronizer.allTransactions().receive()
println("got ${transactions.count()} transaction(s)")
@ -152,8 +152,8 @@ internal class MockSynchronizerTest {
@Test
fun `balance matches transactions with sends`() = runBlocking {
var transactions = listOf<WalletTransaction>()
val balances = fastSynchronizer.start(fastSynchronizer).balance()
var transactions = listOf<ClearedTransaction>()
val balances = fastSynchronizer.start(fastSynchronizer).balances()
val transactionChannel = fastSynchronizer.allTransactions()
while (transactions.count() < 10) {
fastSynchronizer.sendToAddress(Random.nextLong(1L..10_000_000_000), validAddress)