Merge pull request #45 from zcash/task/improve-reorgs-v2
Task/improve reorgs v2
38
build.gradle
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
/build
|
|
@ -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'
|
||||
}
|
|
@ -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
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 4.4 KiB |
After Width: | Height: | Size: 6.7 KiB |
After Width: | Height: | Size: 6.2 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 8.9 KiB |
After Width: | Height: | Size: 15 KiB |
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Zcash Address</string>
|
||||
</resources>
|
|
@ -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>
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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" "$@"
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
include ':app', ":sdk"
|
||||
project(":sdk").projectDir = file("../../../zcash-android-wallet-sdk")
|
||||
rootProject.name='Zcash Address'
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>) {
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
seed-1
|
||||
seed-2
|
||||
seed-3
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"height": 518000,
|
||||
"hash": "000ba586d734c295f0bc034be229b1c96cb040f9d4929efdb5d2b187eeb238fb",
|
||||
"time": 1560645743,
|
||||
"tree": "01a4f5240a88a6eb4ffbda7961a1430506aad1a50ba011593f02c243d968feb0550010000140f91773b4ab669846e5bcb96f60e68256c49a27872a98e9d5ce50b30a0c434e0000018968663d6a7b444591de83f8a07223113f5de7e8203807adacc7677c3bcd4f420194c7ecac0ef6d702d475680ec32051fdf6368af0c459ab450009c001bcbf7a5300000001f0eead5192c3b3ab7208429877570676647e448210332c6da7e18660b142b80e01b98b14cab05247195b3b3be3dd8639bae99a0dd10bed1282ac25b62a134afd7200000000011f8322ef806eb2430dc4a7a41c1b344bea5be946efc7b4349c1c9edb14ff9d39"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"height": 523240,
|
||||
"hash": "00000c33da2196f0ed1bda71043f671fc69a0212e01f892653e212ab358f6b79",
|
||||
"time": 1561002603,
|
||||
"tree": "01d3e02bc1c2d66762f370b329a3063067701ad66c44b40285686bc8ff25f5616f00100154bff87bd0bda3b70a6d7754eca261de15fee3cd9bc53073a232e07fc3261e27000001a54dcaccb4c5e578aef89f2a3b4e3c3d8a487e6e904c5da5916118d721948d07000000000118fa9c6fef4963049dc7002a13bb0021d5e950591e48c9e5f2cbd1199429b80401f0eead5192c3b3ab7208429877570676647e448210332c6da7e18660b142b80e01b98b14cab05247195b3b3be3dd8639bae99a0dd10bed1282ac25b62a134afd7200000000011f8322ef806eb2430dc4a7a41c1b344bea5be946efc7b4349c1c9edb14ff9d39"
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
if (hasChanged(previousTransactions, newTransactions)) {
|
||||
twig("loaded ${newTransactions.count()} transactions and changes were detected!")
|
||||
allTransactionsChannel.send(newTransactions)
|
||||
previousTransactions = newTransactions
|
||||
} else {
|
||||
twig("loaded ${newTransactions.count()} transactions but no changes detected.")
|
||||
}
|
||||
}
|
||||
delay(pollFrequencyMillis)
|
||||
}
|
||||
twig("Done polling for transactions")
|
||||
override suspend fun getClearedTransactions(): List<ClearedTransaction> = withContext(IO) {
|
||||
transactions.getSentTransactions(limit) + transactions.getReceivedTransactions(limit)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
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())
|
||||
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("No changes detected in transactions.")
|
||||
}
|
||||
delay(pollFrequencyMillis)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
// }
|
||||
//
|
||||
//}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)?
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)?
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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],
|
||||
|
@ -14,3 +17,23 @@ 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
|
||||
}
|
|
@ -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>
|
||||
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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.")
|
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|