Refactoring

This commit is contained in:
Kevin Gorham 2019-11-01 16:25:28 -04:00
parent bf48b82aa8
commit 9cb178d6fb
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
52 changed files with 2194 additions and 1629 deletions

View File

@ -196,6 +196,7 @@ dependencies {
// Architecture Components: Room
implementation "androidx.room:room-runtime:${versions.architectureComponents.room}"
implementation "androidx.room:room-common:${versions.architectureComponents.room}"
implementation "androidx.room:room-ktx:${versions.architectureComponents.room}"
implementation "androidx.paging:paging-runtime-ktx:${versions.architectureComponents.paging}"
kapt "androidx.room:room-compiler:${versions.architectureComponents.room}"

View File

@ -23,12 +23,17 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
}
dependencies {
// SDK
implementation project(path: ':sdk')
// implementation "cash.z.android.wallet:zcash-android-testnet:$sdk_version@aar"
// implementation "cash.z.android.wallet:zcash-android-core:$sdk_version@aar"
// implementation "cash.z.android.wallet:zcash-kotlin-bip39:$sdk_version@aar"
// SDK: grpc
implementation "io.grpc:grpc-okhttp:1.21.0"
@ -39,6 +44,7 @@ dependencies {
// SDK: Room
implementation "androidx.room:room-runtime:2.2.0"
implementation "androidx.room:room-common:2.2.0"
implementation "androidx.room:room-ktx:2.2.0"
implementation "androidx.paging:paging-runtime-ktx:2.1.0"
implementation 'com.google.guava:guava:27.0.1-android'
kapt "androidx.room:room-compiler:2.2.0"

View File

@ -5,7 +5,6 @@ import cash.z.wallet.sdk.SdkSynchronizer
import cash.z.wallet.sdk.transaction.*
import cash.z.wallet.sdk.demoapp.util.SampleStorageBridge
import cash.z.wallet.sdk.ext.*
import cash.z.wallet.sdk.secure.Wallet
import cash.z.wallet.sdk.service.LightWalletGrpcService
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
@ -49,12 +48,12 @@ class SampleCodeTest {
/////////////////////////////////////////////////////
// Derive Extended Spending Key
@Test fun deriveSpendingKey() {
val wallet = Wallet()
val privateKeys = wallet.initialize(context, seed)
assertNotNull("Wallet already existed.", privateKeys)
log("Spending Key: ${privateKeys?.get(0)}")
log("Address: ${wallet.getAddress()}")
// val wallet = Wallet()
// val privateKeys = wallet.initialize(context, seed)
// assertNotNull("Wallet already existed.", privateKeys)
//
// log("Spending Key: ${privateKeys?.get(0)}")
// log("Address: ${wallet.getAddress()}")
}
/////////////////////////////////////////////////////
@ -107,24 +106,24 @@ class SampleCodeTest {
// ///////////////////////////////////////////////////
// Create a signed transaction (with memo)
@Test fun createTransaction() = runBlocking {
val wallet = Wallet()
val repository = PagedTransactionRepository(context)
val keyManager = SampleStorageBridge().securelyStoreSeed(seed)
val encoder = WalletTransactionEncoder(wallet, repository, keyManager)
val amount = 0.123.toZec().convertZecToZatoshi()
val address = "ztestsapling1tklsjr0wyw0d58f3p7wufvrj2cyfv6q6caumyueadq8qvqt8lda6v6tpx474rfru9y6u75u7qnw"
val memo = "Test Transaction"
val encodedTx = encoder.create(amount, address, memo ?: "")
// val wallet = Wallet()
// val repository = PagedTransactionRepository(context)
// val keyManager = SampleStorageBridge().securelyStoreSeed(seed)
// val encoder = WalletTransactionEncoder(wallet, repository, keyManager)
// val amount = 0.123.toZec().convertZecToZatoshi()
// val address = "ztestsapling1tklsjr0wyw0d58f3p7wufvrj2cyfv6q6caumyueadq8qvqt8lda6v6tpx474rfru9y6u75u7qnw"
// val memo = "Test Transaction"
// val encodedTx = encoder.create(amount, address, memo ?: "")
}
// ///////////////////////////////////////////////////
// Create a signed transaction (with memo) and broadcast
@Test fun submitTransaction() = runBlocking {
val amount = 0.123.toZec().convertZecToZatoshi()
val address = "ztestsapling1tklsjr0wyw0d58f3p7wufvrj2cyfv6q6caumyueadq8qvqt8lda6v6tpx474rfru9y6u75u7qnw"
val memo = "Test Transaction"
val transaction = synchronizer.sendToAddress(amount, address, memo)
log("transaction: $transaction")
// val amount = 0.123.toZec().convertZecToZatoshi()
// val address = "ztestsapling1tklsjr0wyw0d58f3p7wufvrj2cyfv6q6caumyueadq8qvqt8lda6v6tpx474rfru9y6u75u7qnw"
// val memo = "Test Transaction"
// val transaction = synchronizer.sendToAddress(amount, address, memo)
// log("transaction: $transaction")
}

View File

@ -1,29 +0,0 @@
package cash.z.wallet.sdk.demoapp
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.jni.RustBackendWelding
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* This file represents the dependencies that are specific to this demo. Normally, a dependency
* injection framework like Dagger would provide these objects. For the sake of simplicity, we // TODO finish explaining
*/
object AppInjection {
fun provideRustBackend(prefix: String): RustBackendWelding {
return RustBackend.create(App.instance, "${prefix}_Cache.db", "${prefix}_Data.db")
}
/**
* A sample class that pretends to securely accept a value, store it and return it later. In
* practice, a wallet maker may have a way of securely storing data.
*/
class Vault(var value: String = "") : ReadWriteProperty<Any?, String> {
override fun getValue(thisRef: Any?, property: KProperty<*>): String = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
this.value = value
}
}
}

View File

@ -1,15 +1,15 @@
package cash.z.wallet.sdk.demoapp.demos.getaddress
import android.view.LayoutInflater
import cash.z.wallet.sdk.Initializer
import cash.z.wallet.sdk.demoapp.App
import cash.z.wallet.sdk.demoapp.BaseDemoFragment
import cash.z.wallet.sdk.demoapp.databinding.FragmentGetAddressBinding
import cash.z.wallet.sdk.secure.Wallet
class GetAddressFragment : BaseDemoFragment<FragmentGetAddressBinding>() {
private var seed: ByteArray = App.instance.defaultConfig.seed
private lateinit var wallet: Wallet
private val initializer: Initializer = Initializer(App.instance)
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentGetAddressBinding
= FragmentGetAddressBinding.inflate(layoutInflater)
@ -19,16 +19,14 @@ class GetAddressFragment : BaseDemoFragment<FragmentGetAddressBinding>() {
* Create and initialize the wallet. Initialization will return the private keys but for the
* purposes of this demo we don't need them.
*/
wallet = Wallet().also {
it.initialize(App.instance, seed)
}
initializer.initializeAccounts(seed)
}
override fun onResetComplete() {
binding.textInfo.text = wallet.getAddress()
binding.textInfo.text = initializer.rustBackend.getAddress()
}
override fun onClear() {
wallet.clear()
initializer.clear()
}
}

View File

@ -1,41 +1,45 @@
package cash.z.wallet.sdk.demoapp.demos.getprivatekey
import android.view.LayoutInflater
import cash.z.wallet.sdk.Initializer
import cash.z.wallet.sdk.demoapp.App
import cash.z.wallet.sdk.demoapp.BaseDemoFragment
import cash.z.wallet.sdk.demoapp.databinding.FragmentGetPrivateKeyBinding
import cash.z.wallet.sdk.secure.Wallet
class GetPrivateKeyFragment : BaseDemoFragment<FragmentGetPrivateKeyBinding>() {
private var seed: ByteArray = App.instance.defaultConfig.seed
private lateinit var wallet: Wallet
private lateinit var privateKeys: Array<String>
private val initializer: Initializer = Initializer(App.instance)
private lateinit var spendingKeys: Array<String>
private lateinit var viewingKeys: Array<String>
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentGetPrivateKeyBinding =
FragmentGetPrivateKeyBinding.inflate(layoutInflater)
override fun resetInBackground() {
wallet = Wallet()
/*
* Initialize with the seed and retrieve one private key for each account specified (by
* default, only 1 account is created). In a normal circumstance, a wallet app would then
* store these keys in its secure storage for retrieval, later. Private keys are only needed
* for sending funds.
*
* Since we always clear the wallet, this function call will never return null. Otherwise, we
* would interpret the null case to mean that the wallet data files already exist and
* the private keys were stored externally (i.e. stored securely by the app, not the SDK).
*/
privateKeys = wallet.initialize(App.instance, seed)!!
spendingKeys = initializer.new(seed)
/*
* Viewing keys can be derived from a seed or from spending keys.
*/
viewingKeys = initializer.deriveViewingKeys(seed)
// just for demonstration purposes to show that these approaches produce the same result.
require(spendingKeys.first() == initializer.deriveSpendingKeys(seed).first())
require(viewingKeys.first() == initializer.deriveViewingKey(spendingKeys.first()))
}
override fun onResetComplete() {
binding.textInfo.text = privateKeys[0]
binding.textInfo.text = "Spending Key:\n${spendingKeys[0]}\n\nViewing Key:\n${viewingKeys[0]}"
}
override fun onClear() {
wallet.clear()
initializer.clear()
}
}

View File

@ -36,7 +36,6 @@ class HomeFragment : Fragment() {
super.onResume()
twig("Visiting the home screen clears the default databases, for sanity sake, because " +
"each demo is intended to be self-contained.")
App.instance.getDatabasePath(ZcashSdk.DB_DATA_NAME).absoluteFile.delete()
App.instance.getDatabasePath(ZcashSdk.DB_CACHE_NAME).absoluteFile.delete()
App.instance.getDatabasePath("unusued.db").parentFile.listFiles().forEach { it.delete() }
}
}

View File

@ -1,39 +0,0 @@
package cash.z.wallet.sdk.demoapp.demos.listtransactions
import cash.z.wallet.sdk.transaction.PagedTransactionRepository
import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.transaction.TransactionRepository
import cash.z.wallet.sdk.demoapp.App
import cash.z.wallet.sdk.demoapp.util.SampleStorageBridge
import cash.z.wallet.sdk.secure.Wallet
object Injector {
private val appContext = App.instance
private val sampleSeed = App.instance.defaultConfig.seed
private val birthdayHeight: Int = App.instance.defaultConfig.birthdayHeight
private val host: String = App.instance.defaultConfig.host
private fun provideKeyManager(): Wallet.KeyManager {
return SampleStorageBridge().securelyStoreSeed(sampleSeed)
}
private fun provideWallet(keyManager: Wallet.KeyManager): Wallet {
return Wallet().apply {
initialize(appContext, keyManager.seed, birthdayHeight)?.let { privateKeys ->
keyManager.key = privateKeys.first()
}
}
}
fun provideLedger(): PagedTransactionRepository {
return PagedTransactionRepository(appContext, 2)
}
fun provideSynchronizer(ledger: TransactionRepository): Synchronizer {
val keyManager = provideKeyManager()
return Synchronizer(
appContext, provideWallet(keyManager), host, keyManager,
ledger = ledger
)
}
}

View File

@ -5,14 +5,14 @@ import android.view.View
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagedList
import androidx.recyclerview.widget.LinearLayoutManager
import cash.z.wallet.sdk.SdkSynchronizer
import cash.z.wallet.sdk.Initializer
import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.demoapp.App
import cash.z.wallet.sdk.demoapp.BaseDemoFragment
import cash.z.wallet.sdk.demoapp.databinding.FragmentListTransactionsBinding
import cash.z.wallet.sdk.entity.ReceivedTransaction
import cash.z.wallet.sdk.transaction.PagedTransactionRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.ReceiveChannel
import cash.z.wallet.sdk.entity.ConfirmedTransaction
import cash.z.wallet.sdk.ext.collectWith
import cash.z.wallet.sdk.ext.twig
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@ -21,15 +21,17 @@ import kotlinx.coroutines.launch
* intended to mimic dependency injection.
*/
class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBinding>() {
private lateinit var ledger: PagedTransactionRepository
private val config = App.instance.defaultConfig
private val initializer = Initializer(App.instance)
private lateinit var synchronizer: Synchronizer
private lateinit var adapter: TransactionAdapter<ConfirmedTransaction>
override fun inflateBinding(layoutInflater: LayoutInflater) =
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentListTransactionsBinding =
FragmentListTransactionsBinding.inflate(layoutInflater)
override fun resetInBackground() {
ledger = Injector.provideLedger()
synchronizer = Injector.provideSynchronizer(ledger)
initializer.new(config.seed)
synchronizer = Synchronizer(App.instance, config.host, initializer.rustBackend)
}
override fun onResetComplete() {
@ -37,40 +39,39 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
startSynchronizer()
monitorStatus()
}
override fun onClear() {
ledger.close()
(synchronizer as SdkSynchronizer).apply {
stop()
clearData()
}
}
private fun monitorStatus() {
lifecycleScope.launch {
synchronizer.status.collect { onStatus(it) }
}
synchronizer.stop()
initializer.clear()
}
private fun initTransactionUI() {
binding.recyclerTransactions.layoutManager =
LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
binding.recyclerTransactions.adapter = TransactionAdapter()
adapter = TransactionAdapter()
lifecycleScope.launch {
synchronizer.receivedTransactions.collect { onTransactionsUpdated(it) }
}
binding.recyclerTransactions.adapter = adapter
}
private fun startSynchronizer() {
lifecycleScope.apply {
synchronizer.start(this)
launchProgressMonitor(synchronizer.progress())
}
}
private fun CoroutineScope.launchProgressMonitor(channel: ReceiveChannel<Int>) = launch {
for (i in channel) {
onProgress(i)
}
private fun monitorStatus() {
synchronizer.status.collectWith(lifecycleScope, ::onStatus)
synchronizer.progress.collectWith(lifecycleScope, ::onProgress)
}
// private fun CoroutineScope.launchProgressMonitor(channel: ReceiveChannel<Int>) = launch {
// for (i in channel) {
// onProgress(i)
// }
// }
private fun onProgress(i: Int) {
val message = when (i) {
100 -> "Scanning blocks..."
@ -86,9 +87,10 @@ class ListTransactionsFragment : BaseDemoFragment<FragmentListTransactionsBindin
private fun onSyncComplete() {
binding.textInfo.visibility = View.INVISIBLE
ledger.setTransactionPageListener(this) { t ->
val adapter = binding.recyclerTransactions.adapter as TransactionAdapter
adapter.submitList(t as PagedList<ReceivedTransaction>)
}
}
}
private fun onTransactionsUpdated(transactions: PagedList<ConfirmedTransaction>) {
twig("got a new paged list of transactions")
adapter.submitList(transactions)
}
}

View File

@ -5,36 +5,33 @@ import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import cash.z.wallet.sdk.demoapp.R
import cash.z.wallet.sdk.entity.ReceivedTransaction
import cash.z.wallet.sdk.entity.ConfirmedTransaction
class TransactionAdapter :
PagedListAdapter<ReceivedTransaction, TransactionViewHolder>(
DIFF_CALLBACK
class TransactionAdapter<T : ConfirmedTransaction> :
PagedListAdapter<T, TransactionViewHolder<T>>(
object : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(
oldItem: T,
newItem: T
) = oldItem.minedHeight == newItem.minedHeight
override fun areContentsTheSame(
oldItem: T,
newItem: T
) = oldItem.equals(newItem)
}
) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
) = TransactionViewHolder(
) = TransactionViewHolder<T>(
LayoutInflater.from(parent.context).inflate(R.layout.item_transaction, parent, false)
)
override fun onBindViewHolder(
holder: TransactionViewHolder,
holder: TransactionViewHolder<T>,
position: Int
) = holder.bindTo(getItem(position))
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ReceivedTransaction>() {
override fun areItemsTheSame(
oldItem: ReceivedTransaction,
newItem: ReceivedTransaction
) = oldItem.minedHeight == newItem.minedHeight
override fun areContentsTheSame(
oldItem: ReceivedTransaction,
newItem: ReceivedTransaction
) = oldItem.equals(newItem)
}
}
}

View File

@ -4,17 +4,17 @@ import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import cash.z.wallet.sdk.demoapp.R
import cash.z.wallet.sdk.entity.ReceivedTransaction
import cash.z.wallet.sdk.entity.ConfirmedTransaction
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import java.text.SimpleDateFormat
import java.util.*
class TransactionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val amountText = itemView.findViewById<TextView>(R.id.text_transaction_amount)
private val timeText = itemView.findViewById<TextView>(R.id.text_transaction_timestamp)
private val formatter = SimpleDateFormat("M/d h:mma", Locale.getDefault())
fun bindTo(transaction: ReceivedTransaction?) {
fun bindTo(transaction: T?) {
amountText.text = transaction?.value.convertZatoshiToZecString()
timeText.text =
if (transaction == null || transaction?.blockTimeInSeconds == 0L) "Pending"

View File

@ -3,68 +3,64 @@ package cash.z.wallet.sdk.demoapp.demos.send
import android.view.LayoutInflater
import android.view.View
import android.widget.Toast
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.lifecycleScope
import cash.z.wallet.sdk.SdkSynchronizer
import cash.z.wallet.sdk.Initializer
import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.block.CompactBlockProcessor
import cash.z.wallet.sdk.demoapp.App
import cash.z.wallet.sdk.demoapp.BaseDemoFragment
import cash.z.wallet.sdk.demoapp.databinding.FragmentSendBinding
import cash.z.wallet.sdk.demoapp.util.SampleStorageBridge
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.convertZecToZatoshi
import cash.z.wallet.sdk.ext.toZec
import cash.z.wallet.sdk.secure.Wallet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.filterNot
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.ext.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
private val sampleSeed = App.instance.defaultConfig.seed
private val birthdayHeight: Int = App.instance.defaultConfig.birthdayHeight
private val host: String = App.instance.defaultConfig.host
private val config = App.instance.defaultConfig
private val initializer = Initializer(App.instance)
private lateinit var synchronizer: Synchronizer
private lateinit var keyManager: SampleStorageBridge
override fun inflateBinding(layoutInflater: LayoutInflater): FragmentSendBinding =
FragmentSendBinding.inflate(layoutInflater)
override fun resetInBackground() {
val keyManager = SampleStorageBridge().securelyStoreSeed(sampleSeed)
synchronizer =
Synchronizer(App.instance, host, keyManager, birthdayHeight)
val spendingKeys = initializer.new(config.seed)
keyManager = SampleStorageBridge().securelyStorePrivateKey(spendingKeys[0])
synchronizer = Synchronizer(App.instance, config.host, initializer.rustBackend)
}
override fun onResetComplete() {
lifecycle.coroutineScope.apply {
synchronizer.start(this)
launchProgressMonitor(synchronizer.progress())
launchBalanceMonitor(synchronizer.balances())
}
initSendUI()
startSynchronizer()
monitorStatus()
}
private fun initSendUI() {
binding.buttonSend.setOnClickListener(::onSend)
}
private fun startSynchronizer() {
lifecycleScope.apply {
synchronizer.start(this)
}
}
private fun monitorStatus() {
synchronizer.status.collectWith(lifecycleScope, ::onStatus)
synchronizer.progress.collectWith(lifecycleScope, ::onProgress)
synchronizer.balances.collectWith(lifecycleScope, ::onBalance)
}
private fun onStatus(status: Synchronizer.Status) {
binding.textStatus.text = "Status: $status"
}
override fun onClear() {
// remove the stored databases
(synchronizer as SdkSynchronizer).clearData()
synchronizer.stop()
}
private fun CoroutineScope.launchProgressMonitor(channel: ReceiveChannel<Int>) = launch {
for (i in channel) {
onProgress(i)
}
}
private fun CoroutineScope.launchBalanceMonitor(
channel: ReceiveChannel<Wallet.WalletBalance>
) = launch {
val positiveBalances = channel.filterNot { it.total < 0 }
for (i in positiveBalances) {
onBalance(i)
}
initializer.clear()
}
private fun onProgress(i: Int) {
@ -75,7 +71,7 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
binding.textStatus.text = message
}
private fun onBalance(balance: Wallet.WalletBalance) {
private fun onBalance(balance: CompactBlockProcessor.WalletBalance) {
binding.textBalances.text = """
Available balance: ${balance.available.convertZatoshiToZecString()}
Total balance: ${balance.total.convertZatoshiToZecString()}
@ -86,9 +82,23 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
private fun onSend(unused: View) {
// TODO: add input fields to the UI. Possibly, including a scanner for the address input
lifecycleScope.launch {
synchronizer.sendToAddress(0.001.toZec().convertZecToZatoshi(), "ztestsapling1fg82ar8y8whjfd52l0xcq0w3n7nn7cask2scp9rp27njeurr72ychvud57s9tu90fdqgwdt07lg", "Demo App Funds")
synchronizer.sendToAddress(
keyManager.key,
0.0024.toZec().convertZecToZatoshi(),
config.toAddress,
"Demo App Funds"
).collectWith(lifecycleScope, ::onPendingTxUpdated)
}
private fun onPendingTxUpdated(pendingTransaction: PendingTransaction) {
val message = when {
pendingTransaction.isSubmitted() -> "Successfully submitted transaction!"
pendingTransaction.isMined() -> "Transaction Mined!"
pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction!"
pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction!"
pendingTransaction.isCreating() -> "Creating transaction!"
else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") }
}
Toast.makeText(App.instance, "Sending funds...", Toast.LENGTH_SHORT).show()
Toast.makeText(App.instance, message, Toast.LENGTH_SHORT).show()
}
}

View File

@ -5,7 +5,8 @@ data class DemoConfig(
val port: Int = 9067,
val birthdayHeight: Int = 620_000,//523_240,
val network: ZcashNetwork = ZcashNetwork.TEST_NET,
val seed: ByteArray = "testreferencealice".toByteArray()
val seed: ByteArray = "testreferencealice".toByteArray(),
val toAddress: String = "ztestsapling1fg82ar8y8whjfd52l0xcq0w3n7nn7cask2scp9rp27njeurr72ychvud57s9tu90fdqgwdt07lg"
)
enum class ZcashNetwork { MAIN_NET, TEST_NET }

View File

@ -2,7 +2,6 @@ package cash.z.wallet.sdk.demoapp.util
import android.content.Context
import cash.z.wallet.sdk.demoapp.App
import cash.z.wallet.sdk.secure.Wallet
@Deprecated(
@ -31,28 +30,35 @@ class SampleStorage {
/**
* Simple demonstration of how to take existing code that securely stores data and bridge it into
* the KeyManager interface. This class implements the interface by delegating to the storage
* object. For demo purposes, we're using an insecure SampleStorage implementation but this can
* easily be swapped for a true storage solution.
* the SDK. This class delegates to the storage object. For demo purposes, we're using an insecure
* SampleStorage implementation but this can easily be swapped for a truly secure storage solution.
*/
class SampleStorageBridge(): Wallet.KeyManager {
private val KEY_SEED = "cash.z.wallet.sdk.demoapp.SEED"
private val KEY_PK = "cash.z.wallet.sdk.demoapp.PK"
class SampleStorageBridge() {
private val delegate = SampleStorage()
constructor(seed: ByteArray) : this() {
securelyStoreSeed(seed)
}
/**
* Just a sugar method to help with being explicit in sample code. We want to show developers
* our intention that they write simple bridges to secure storage components.
*/
fun securelyStoreSeed(seed: ByteArray): SampleStorageBridge {
delegate.saveSensitiveBytes(KEY_SEED, seed)
return this
}
override val seed: ByteArray get() = delegate.loadSensitiveBytes(KEY_SEED)!!
override var key: String
get() = delegate.loadSensitiveString(KEY_PK)!!
set(value) {
delegate.saveSensitiveString(KEY_PK, value)
}
/**
* Just a sugar method to help with being explicit in sample code. We want to show developers
* our intention that they write simple bridges to secure storage components.
*/
fun securelyStorePrivateKey(key: String): SampleStorageBridge {
delegate.saveSensitiveString(KEY_PK, key)
return this
}
val seed: ByteArray get() = delegate.loadSensitiveBytes(KEY_SEED)!!
val key get() = delegate.loadSensitiveString(KEY_PK)!!
companion object {
private const val KEY_SEED = "cash.z.wallet.sdk.demoapp.SEED"
private const val KEY_PK = "cash.z.wallet.sdk.demoapp.PK"
}
}

View File

@ -10,7 +10,7 @@ buildscript {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.0-beta01'
classpath 'com.android.tools.build:gradle:3.6.0-beta02'
classpath"org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

View File

@ -48,7 +48,7 @@ class IntegrationTest {
@Test(timeout = 120_000L)
fun testSync() = runBlocking<Unit> {
val rustBackend = RustBackend.create(context)
val rustBackend = RustBackend.init(context)
val lightwalletService = LightWalletGrpcService(context,"192.168.1.134")
val compactBlockStore = CompactBlockDbStore(context)

View File

@ -1,9 +1,9 @@
package cash.z.wallet.sdk.jni
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.BeforeClass
import org.junit.Test
class RustBackendTest {
@ -43,7 +43,7 @@ class RustBackendTest {
}
companion object {
val rustBackend: RustBackendWelding = RustBackend.create(ApplicationProvider.getApplicationContext(), "rustTestCache.db", "rustTestData.db")
val rustBackend: RustBackendWelding = RustBackend.init(ApplicationProvider.getApplicationContext() as Context, "rustTest")
}
}

View File

@ -23,7 +23,7 @@ class AddressGeneratorUtil {
private val dataDbName = "AddressUtilData.db"
private val context = InstrumentationRegistry.getInstrumentation().context
private val rustBackend = RustBackend.create(context)
private val rustBackend = RustBackend.init(context)
private lateinit var wallet: Wallet

View File

@ -36,7 +36,7 @@ class BalancePrinterUtil {
private val context = InstrumentationRegistry.getInstrumentation().context
private val cacheDbName = "BalanceUtilCache.db"
private val dataDbName = "BalanceUtilData.db"
private val rustBackend = RustBackend.create(context, cacheDbName, dataDbName)
private val rustBackend = RustBackend.init(context, cacheDbName, dataDbName)
private val downloader = CompactBlockDownloader(
LightWalletGrpcService(context, host),

View File

@ -0,0 +1,360 @@
package cash.z.wallet.sdk
import android.content.Context
import android.content.SharedPreferences
import cash.z.wallet.sdk.exception.BirthdayException
import cash.z.wallet.sdk.exception.BirthdayException.MissingBirthdayException
import cash.z.wallet.sdk.exception.InitializerException
import cash.z.wallet.sdk.ext.*
import cash.z.wallet.sdk.jni.RustBackend
import com.google.gson.Gson
import com.google.gson.stream.JsonReader
import java.io.InputStreamReader
/**
* Responsible for initialization, which can be considered as setup that must happen before
* synchronizing begins. This begins with one of three actions, a call to either [new], [import] or
* [open], where the last option is the most common case--when a user is opening a wallet they have
* used before on this device.
*/
class Initializer(
appContext: Context,
private val alias: String = ZcashSdk.DEFAULT_DB_NAME_PREFIX
) {
init {
validateAlias(alias)
}
/**
* The path this initializer will use when creating instances of Rustbackend. This value is
* derived from the appContext when this class is constructed.
*/
private val dbPath: String = appContext.getDatabasePath("unused.db").parentFile?.absolutePath
?: throw InitializerException.DatabasePathException
/**
* The path this initializer will use when cheching for and downloaading sapling params. This
* value is derived from the appContext when this class is constructed.
*/
private val paramPath: String = "${appContext.cacheDir.absolutePath}/params"
/**
* Preferences where the birthday is stored.
*/
private val prefs: SharedPreferences = SharedPrefs(appContext, alias)
/**
* A wrapped version of [cash.z.wallet.sdk.jni.RustBackendWelding] that will be passed to the
* SDK when it is constructed. It provides access to all Librustzcash features and is configured
* based on this initializer.
*/
lateinit var rustBackend: RustBackend
/**
* The birthday that was ultimately used for initializing the accounts.
*/
lateinit var birthday: WalletBirthday
/**
* Birthday that helps new wallets not have to scan from the beginning, which saves significant
* amounts of startup time. This value is created using the context passed into the constructor.
*/
private var newWalletBirthday: WalletBirthday = loadBirthdayFromAssets(appContext)
/**
* Birthday to use whenever no birthday is known, meaning we have to scan from the first time a
* transaction could have happened. This is the most efficient value we can use in this least
* efficient circumstance. This value is created using the context passed into the constructor
* and it is a different value for mainnet and testnet.
*/
private var saplingBirthday: WalletBirthday =
loadBirthdayFromAssets(appContext, ZcashSdk.SAPLING_ACTIVATION_HEIGHT)
/**
* Typically the first function that is called on this class in order to determine whether the
* user needs to create a new wallet.
*
* @return true when an initialized wallet exists on this device.
*/
fun hasData() = prefs.get<Boolean>("hasData") == true
/**
* Initialize a new wallet with the given seed and birthday. It creates the required database
* tables and loads and configures the [rustBackend] property for use by all other components.
*
* @return the account spending keys, corresponding to the accounts that get initialized in the
* DB.
*/
fun new(
seed: ByteArray,
birthday: WalletBirthday = newWalletBirthday,
numberOfAccounts: Int = 1
): Array<String> {
initRustLibrary()
return initializeAccounts(seed, birthday, numberOfAccounts)
}
/**
* Initialize a new wallet with the imported seed and birthday. It creates the required database
* tables and loads and configures the [rustBackend] property for use by all other components.
*
* @return the account spending keys, corresponding to the accounts that get initialized in the
* DB.
*/
fun import(
seed: ByteArray,
birthday: WalletBirthday = saplingBirthday
): Array<String> {
initRustLibrary()
return initializeAccounts(seed, birthday)
}
/**
* Loads the rust library and previously used birthday for use by all other components. This is
* the most common use case for the initializer--reopening a wallet that was previously created.
*/
fun open(): Initializer {
initRustLibrary()
birthday = loadBirthdayFromPrefs(prefs) ?: throw MissingBirthdayException(alias)
rustBackend.birthdayHeight = birthday.height
return this
}
/**
* Initializes the databases that the rust library uses for managing state. The "data db" is
* created and a row is entered corresponding to the given birthday so that scanning does not
* need to start from the beginning of time. Lastly, the accounts table is initialized to
* simply hold the address and viewing key for each account, which simplifies the process of
* scanning and decrypting compact blocks.
*
* @return the spending keys for each account, ordered by index. These keys are only needed for
* spending funds.
*/
fun initializeAccounts(
seed: ByteArray,
birthday: WalletBirthday = newWalletBirthday,
numberOfAccounts: Int = 1
): Array<String> {
this.birthday = birthday
try {
// only creates tables, if they don't exist
rustBackend.initDataDb()
twig("Initialized wallet for first run")
} catch (t: Throwable) {
throw InitializerException.FalseStart(t)
}
try {
rustBackend.initBlocksTable(
birthday.height,
birthday.hash,
birthday.time,
birthday.tree
)
twig("seeded the database with sapling tree at height ${birthday.height}")
} catch (t: Throwable) {
if (t.message?.contains("is not empty") == true) {
throw InitializerException.AlreadyInitializedException(t)
} else {
throw InitializerException.FalseStart(t)
}
}
try {
return rustBackend.initAccountsTable(seed, numberOfAccounts).also {
twig("Initialized the accounts table with ${numberOfAccounts} account(s)")
}
} catch (t: Throwable) {
throw InitializerException.FalseStart(t)
}
onAccountsInitialized()
}
/**
* Delete all local data related to this wallet, as though the wallet was never created on this
* device. Simply put, this call deletes the "cache db" and "data db."
*/
fun clear() {
rustBackend.clear()
}
/**
* Validate that the alias doesn't contain malicious characters by enforcing simple rules which
* permit the alias to be used as part of a file name for the preferences and databases. This
* enables multiple wallets to exist on one device, which is also helpful for sweeping funds.
*
* @throws IllegalArgumentException whenever the alias is not less than 100 characters or
* contains something other than alphanumeric characters. Underscores are allowed but aliases
* must start with a letter.
*/
private fun validateAlias(alias: String) {
require(alias.length in 1..99 && alias[0].isLetter()
&& alias.all{ it.isLetterOrDigit() || it == '_' }) {
"ERROR: Invalid alias ($alias). For security, the alias must be shorter than 100 " +
"characters and only contain letters, digits or underscores and start with a letter."
}
}
/**
* Called when accounts have been successfully initialized. Stores the birthday and a flag to
* signal that initialization has happened for the given alias.
*/
private fun onAccountsInitialized() {
saveBirthdayToPrefs(prefs, birthday)
prefs[PREFS_HAS_DATA] = true
}
/**
* Lazily initializes the rust backend, using values that were captured from the appContext
* that was passed to the constructor.
*/
private fun initRustLibrary() {
if (!::rustBackend.isInitialized) rustBackend = RustBackend().init(dbPath, paramPath, alias)
}
//
// Key Derivation Helpers
//
/**
* Given a seed and a number of accounts, return the associated spending keys. These keys can
* be used to derive the viewing keys.
*
* @return the spending keys that correspond to the seed, formatted as Strings.
*/
fun deriveSpendingKeys(seed: ByteArray, numberOfAccounts: Int = 1): Array<String> {
initRustLibrary()
return rustBackend.deriveSpendingKeys(seed, numberOfAccounts)
}
/**
* Given a seed and a number of accounts, return the associated viewing keys.
*
* @return the viewing keys that correspond to the seed, formatted as Strings.
*/
fun deriveViewingKeys(seed: ByteArray, numberOfAccounts: Int = 1): Array<String> {
initRustLibrary()
return rustBackend.deriveViewingKeys(seed, numberOfAccounts)
}
/**
* Given a spending key, return the associated viewing key.
*
* @return the viewing key that corresponds to the spending key.
*/
fun deriveViewingKey(spendingKey: String): String = rustBackend.deriveViewingKey(spendingKey)
/**
* Static helper functions that facilitate initializing the birthday.
*/
companion object {
//
// Preference Keys
//
private const val PREFS_HAS_DATA = "Initializer.prefs.hasData"
private const val PREFS_BIRTHDAY_HEIGHT = "Initializer.prefs.birthday.height"
private const val PREFS_BIRTHDAY_TIME = "Initializer.prefs.birthday.time"
private const val PREFS_BIRTHDAY_HASH = "Initializer.prefs.birthday.hash"
private const val PREFS_BIRTHDAY_TREE = "Initializer.prefs.birthday.tree"
/**
* Directory within the assets folder where birthday data
* (i.e. sapling trees for a given height) can be found.
*/
private const val BIRTHDAY_DIRECTORY = "zcash/saplingtree"
/**
* Load the given birthday file from the assets of the given context. When no height is
* specified, we default to the file with the greatest name.
*
* @param context the context from which to load assets.
* @param birthdayHeight the height file to look for among the file names.
*
* @return a WalletBirthday that reflects the contents of the file or an exception when
* parsing fails.
*/
fun loadBirthdayFromAssets(context: Context, birthdayHeight: Int? = null): WalletBirthday {
val treeFiles =
context.assets.list(BIRTHDAY_DIRECTORY)?.apply { sortDescending() }
if (treeFiles.isNullOrEmpty()) throw BirthdayException.MissingBirthdayFilesException(
BIRTHDAY_DIRECTORY
)
val file: String
try {
file = treeFiles.first() {
if (birthdayHeight == null) true
else it.contains(birthdayHeight.toString())
}
} catch (t: Throwable) {
throw BirthdayException.BirthdayFileNotFoundException(
BIRTHDAY_DIRECTORY,
birthdayHeight
)
}
try {
val reader = JsonReader(
InputStreamReader(context.assets.open("${BIRTHDAY_DIRECTORY}/$file"))
)
return Gson().fromJson(reader, WalletBirthday::class.java)
} catch (t: Throwable) {
throw BirthdayException.MalformattedBirthdayFilesException(
BIRTHDAY_DIRECTORY,
treeFiles[0]
)
}
}
/**
* Retrieves the birthday-related primitives from the given preference object and then uses
* them to construct and return a birthday instance. It assumes that if the first preference
* is there, the rest will be too. If that's not the case, a call to this function will
* result in an exception.
*
* @return a birthday from preferences if one exists and null, otherwise null
*/
fun loadBirthdayFromPrefs(prefs: SharedPreferences?): WalletBirthday? {
prefs ?: return null
val height: Int? = prefs[PREFS_BIRTHDAY_HEIGHT]
return height?.let {
runCatching {
WalletBirthday(
it,
prefs[PREFS_BIRTHDAY_HASH]!!,
prefs[PREFS_BIRTHDAY_TIME]!!,
prefs[PREFS_BIRTHDAY_TREE]!!
)
}.getOrNull()
}
}
/**
* Save the given birthday to the given preferences.
*
* @param prefs the shared preferences to use
* @param birthday the birthday to save. It will be split into primitives.
*/
fun saveBirthdayToPrefs(prefs: SharedPreferences, birthday: WalletBirthday) {
prefs[PREFS_BIRTHDAY_HEIGHT] = birthday.height
prefs[PREFS_BIRTHDAY_HASH] = birthday.hash
prefs[PREFS_BIRTHDAY_TIME] = birthday.time
prefs[PREFS_BIRTHDAY_TREE] = birthday.tree
}
}
/**
* Model object for holding wallet birthdays. It is only used by this class.
*/
data class WalletBirthday(
val height: Int = -1,
val hash: String = "",
val time: Long = -1,
val tree: String = ""
)
}

View File

@ -1,117 +1,80 @@
package cash.z.wallet.sdk
import android.content.Context
import androidx.paging.PagedList
import cash.z.wallet.sdk.Synchronizer.Status.*
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.CompactBlockProcessor.State.*
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
import cash.z.wallet.sdk.block.CompactBlockStore
import cash.z.wallet.sdk.Synchronizer.Status.*
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.entity.*
import cash.z.wallet.sdk.exception.SynchronizerException
import cash.z.wallet.sdk.exception.WalletException
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.secure.Wallet
import cash.z.wallet.sdk.ext.twigTask
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.service.LightWalletGrpcService
import cash.z.wallet.sdk.service.LightWalletService
import cash.z.wallet.sdk.transaction.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.*
import kotlin.coroutines.CoroutineContext
/**
* Constructor function for building a Synchronizer, given the bare minimum amount of information
* necessary to do so.
*/
@Suppress("FunctionName")
fun Synchronizer(
appContext: Context,
lightwalletdHost: String,
keyManager: Wallet.KeyManager,
birthdayHeight: Int? = null
): Synchronizer {
val wallet = Wallet().also {
val privateKeyMaybe = it.initialize(appContext, keyManager.seed, birthdayHeight)
if (privateKeyMaybe != null) keyManager.key = privateKeyMaybe[0]
}
return Synchronizer(
appContext,
wallet,
lightwalletdHost,
keyManager
)
}
/**
* Constructor function for building a Synchronizer in the most flexible way possible. This allows
* a wallet maker to customize any subcomponent of the Synchronzier.
*/
@Suppress("FunctionName")
fun Synchronizer(
appContext: Context,
wallet: Wallet,
lightwalletdHost: String,
keyManager: Wallet.KeyManager,
ledger: TransactionRepository = PagedTransactionRepository(appContext),
manager: TransactionManager = PersistentTransactionManager(appContext),
service: LightWalletService = LightWalletGrpcService(appContext, lightwalletdHost),
sender: TransactionSender = PersistentTransactionSender(manager, service, ledger),
blockStore: CompactBlockStore = CompactBlockDbStore(appContext),
downloader: CompactBlockDownloader = CompactBlockDownloader(service, blockStore),
processor: CompactBlockProcessor = CompactBlockProcessor(downloader, ledger, wallet.rustBackend, wallet.lowerBoundHeight),
encoder: TransactionEncoder = WalletTransactionEncoder(wallet, ledger, keyManager)
): Synchronizer {
// ties everything together
return SdkSynchronizer(
wallet,
ledger,
sender,
processor,
encoder
)
}
/**
* A synchronizer that attempts to remain operational, despite any number of errors that can occur. It acts as the glue
* that ties all the pieces of the SDK together. Each component of the SDK is designed for the potential of stand-alone
* usage but coordinating all the interactions is non-trivial. So the synchronizer facilitates this, acting as reference
* that demonstrates how all the pieces can be tied together. Its goal is to allow a developer to focus on their app
* rather than the nuances of how Zcash works.
* A Synchronizer that attempts to remain operational, despite any number of errors that can occur.
* It acts as the glue that ties all the pieces of the SDK together. Each component of the SDK is
* designed for the potential of stand-alone usage but coordinating all the interactions is non-
* trivial. So the Synchronizer facilitates this, acting as reference that demonstrates how all the
* pieces can be tied together. Its goal is to allow a developer to focus on their app rather than
* the nuances of how Zcash works.
*
* @param wallet An initialized wallet. This component wraps the rust backend and manages wallet config.
* @param repository the component that exposes streams of wallet transaction information.
* @param sender the component responsible for sending transactions to lightwalletd in order to spend funds.
* @param processor the component that saves the downloaded compact blocks to the cache and then scans those blocks for
* @param ledger exposes flows of wallet transaction information.
* @param manager manages and tracks outbound transactions.
* @param processor saves the downloaded compact blocks to the cache and then scans those blocks for
* data related to this wallet.
* @param encoder the component that creates a signed transaction, used for spending funds.
*/
@ExperimentalCoroutinesApi
class SdkSynchronizer (
private val wallet: Wallet,
private val ledger: TransactionRepository,
private val sender: TransactionSender,
private val processor: CompactBlockProcessor,
private val encoder: TransactionEncoder
class SdkSynchronizer internal constructor(
val ledger: TransactionRepository,
private val manager: OutboundTransactionManager,
private val processor: CompactBlockProcessor
) : Synchronizer {
private val balanceChannel = ConflatedBroadcastChannel<WalletBalance>()
/**
* 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.
* 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
//
// Transactions
//
// TODO: implement this stuff
override val balances: Flow<WalletBalance> = balanceChannel.asFlow()
override val allTransactions: Flow<PagedList<Transaction>> = flow{}
override val pendingTransactions: Flow<PagedList<PendingTransaction>> = flow{}
override val clearedTransactions: Flow<PagedList<ConfirmedTransaction>> = flow{}
override val sentTransactions: Flow<PagedList<ConfirmedTransaction>> = ledger.sentTransactions
override val receivedTransactions: Flow<PagedList<ConfirmedTransaction>> = ledger.receivedTransactions
//
// Status
//
@ -129,25 +92,12 @@ class SdkSynchronizer (
}
}
//
// Communication Primitives
//
/**
* Channel of balance information.
* Indicates the download progress of the Synchronizer. When progress reaches 100, that
* signals that the Synchronizer is in sync with the network. Balances should be considered
* inaccurate and outbound transactions should be prevented until this sync is complete.
*/
private val balanceChannel = ConflatedBroadcastChannel(Wallet.WalletBalance())
/**
* Channel of data representing transactions that are pending.
*/
private val pendingChannel = ConflatedBroadcastChannel<List<PendingTransaction>>(listOf())
/**
* Channel of data representing transactions that have been mined.
*/
private val clearedChannel = ConflatedBroadcastChannel<List<ClearedTransaction>>(listOf())
override val progress: Flow<Int> = processor.progress
//
@ -155,138 +105,104 @@ class SdkSynchronizer (
//
/**
* A callback to invoke whenever an uncaught error is encountered. By definition, the return value of the function
* is ignored because this error is unrecoverable. The only reason the function has a return value is so that all
* error handlers work with the same signature which allows one function to handle all errors in simple apps. This
* callback is not called on the main thread so any UI work would need to switch context to the main thread.
* A callback to invoke whenever an uncaught error is encountered. By definition, the return
* value of the function is ignored because this error is unrecoverable. The only reason the
* function has a return value is so that all error handlers work with the same signature which
* allows one function to handle all errors in simple apps. This callback is not called on the
* main thread so any UI work would need to switch context to the main thread.
*/
override var onCriticalErrorHandler: ((Throwable?) -> Boolean)? = null
/**
* A callback to invoke whenever a processor error is encountered. Returning true signals that the error was handled
* and a retry attempt should be made, if possible. This callback is not called on the main thread so any UI work
* would need to switch context to the main thread.
* A callback to invoke whenever a processor error is encountered. Returning true signals that
* the error was handled and a retry attempt should be made, if possible. This callback is not
* called on the main thread so any UI work would need to switch context to the main thread.
*/
override var onProcessorErrorHandler: ((Throwable?) -> Boolean)? = null
/**
* A callback to invoke whenever a server error is encountered while submitting a transaction to lightwalletd.
* Returning true signals that the error was handled and a retry attempt should be made, if possible. This callback
* is not called on the main thread so any UI work would need to switch context to the main thread.
* A callback to invoke whenever a server error is encountered while submitting a transaction to
* lightwalletd. Returning true signals that the error was handled and a retry attempt should be
* made, if possible. This callback is not called on the main thread so any UI work would need
* to switch context to the main thread.
*/
override var onSubmissionErrorHandler: ((Throwable?) -> Boolean)? = null
//
// Public API
//
/**
* 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.
* 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.
* @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 {
if (::coroutineScope.isInitialized) throw SynchronizerException.FalseStart
// 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 {
startSender(this)
launchProgressMonitor()
launchPendingMonitor()
onReady()
}
coroutineScope =
CoroutineScope(SupervisorJob(parentScope.coroutineContext[Job]!!) + Dispatchers.Main)
coroutineScope.onReady()
return this
}
/**
* Initializes the sender such that it can initiate requests within the scope of this synchronizer.
*/
private fun startSender(parentScope: CoroutineScope) {
sender.onSubmissionError = ::onFailedSend
sender.start(parentScope)
}
// TODO: this is a work in progress. We could take drastic measures to automatically recover from certain critical
// errors and alert the user but this might be better to do at the app level, rather than SDK level.
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
}
/**
* Stop this synchronizer and all of its child jobs. Once a synchronizer has been stopped it should not be restarted
* and attempting to do so will result in an error. Also, this function will throw an exception if the synchronizer
* was never previously started.
* Stop this synchronizer and all of its child jobs. Once a synchronizer has been stopped it
* should not be restarted and attempting to do so will result in an error. Also, this function
* will throw an exception if the synchronizer was never previously started.
*/
override fun stop() {
coroutineScope.launch {
processor.stop()
coroutineScope.cancel()
balanceChannel.cancel()
}
}
fun clearData() {
wallet.clear()
}
//
// Monitors
// Private API
//
// 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 (j/k)")
//refreshBalance()
}
}
twig("done monitoring for progress changes")
private fun refreshTransactions() {
ledger.invalidate()
}
// 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 (j/kk)")
// refreshBalance()
}
twig("done monitoring for pending changes and balance changes")
}
suspend fun refreshBalance() = withContext(IO) {
if (!balanceChannel.isClosedForSend) {
balanceChannel.send(wallet.getBalanceInfo())
} else {
twig("WARNING: noticed new transactions but the balance channel was closed for send so ignoring!")
}
private suspend fun refreshBalance() {
balanceChannel.send(processor.getBalanceInfo())
}
private fun CoroutineScope.onReady() = launch(CoroutineExceptionHandler(::onCriticalError)) {
twig("Synchronizer Ready. Starting processor!")
processor.onErrorListener = ::onProcessorError
status.filter { it == SYNCED }.onEach {
twig("Triggering an automatic balance refresh since the processor is synced!")
refreshBalance()
// TRICKY:
// Keep an eye on this section because there is a potential for concurrent DB
// modification. A change in transactions means a change in balance. Calculating the
// balance requires touching transactions. If both are done in separate threads, the
// database can have issues. On Android, would manifest as a false positive for a
// "malformed database" exception when the database is not actually corrupt but rather
// locked (i.e. it's a bad error message).
// The balance refresh is done first because it is coroutine-based and will fully
// complete by the time the function returns.
// Ultimately, refreshing the transactions just invalidates views of data that
// already exists and it completes on another thread so it should come after the
// balance refresh is complete.
twigTask("Triggering an automatic balance refresh since the processor is synced!") {
refreshBalance()
}
twigTask("Triggering an automatic transaction refresh since the processor is synced!") {
refreshTransactions()
}
}.launchIn(this)
processor.start()
twig("Synchronizer onReady complete. Processor start has exited!")
@ -328,56 +244,52 @@ class SdkSynchronizer (
}
//
// Channels
//
override fun balances(): ReceiveChannel<Wallet.WalletBalance> {
return balanceChannel.openSubscription()
}
override fun progress(): ReceiveChannel<Int> = processor.progress()
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 cancelSpend(transaction: PendingTransaction) = manager.cancel(transaction)
override suspend fun getAddress(accountId: Int): String = withContext(IO) { wallet.getAddress() }
override suspend fun getAddress(accountId: Int): String = processor.getAddress(accountId)
override suspend fun sendToAddress(
override fun sendToAddress(
spendingKey: String,
zatoshi: Long,
toAddress: String,
memo: String,
fromAccountId: Int
): PendingTransaction = withContext(IO) {
sender.sendToAddress(encoder, zatoshi, toAddress, memo, fromAccountId)
fromAccountIndex: Int
): Flow<PendingTransaction> {
twig("beginning sendToAddress")
return manager.initSpend(zatoshi, toAddress, memo, fromAccountIndex).flatMapConcat {
manager.encode(spendingKey, it)
}.flatMapConcat {
manager.submit(it)
}
}
}
/**
* Constructor function for building a Synchronizer in the most flexible way possible. This allows
* a wallet maker to customize any subcomponent of the Synchronzier.
*/
@Suppress("FunctionName")
fun Synchronizer(
appContext: Context,
lightwalletdHost: String,
rustBackend: RustBackend,
ledger: TransactionRepository = PagedTransactionRepository(appContext, 10, rustBackend.dbDataPath),
blockStore: CompactBlockStore = CompactBlockDbStore(appContext, rustBackend.dbCachePath),
service: LightWalletService = LightWalletGrpcService(appContext, lightwalletdHost),
encoder: TransactionEncoder = WalletTransactionEncoder(rustBackend, ledger),
downloader: CompactBlockDownloader = CompactBlockDownloader(service, blockStore),
manager: OutboundTransactionManager = PersistentTransactionManager(appContext, encoder, service),
processor: CompactBlockProcessor = CompactBlockProcessor(downloader, ledger, rustBackend, rustBackend.birthdayHeight)
): Synchronizer {
// ties everything together
return SdkSynchronizer(
ledger,
manager,
processor
)
}

View File

@ -1,17 +1,16 @@
package cash.z.wallet.sdk
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 androidx.paging.PagedList
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
import cash.z.wallet.sdk.entity.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.Flow
/**
* 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.
* 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.
*/
interface Synchronizer {
@ -22,67 +21,24 @@ interface Synchronizer {
/**
* Starts this synchronizer within the given scope.
*
* @param parentScope the scope to use for this synchronizer, typically something with a lifecycle such as an
* Activity. Implementations should leverage structured concurrency and cancel all jobs when this scope completes.
* @param parentScope the scope to use for this synchronizer, typically something with a
* lifecycle such as an Activity. Implementations should leverage structured concurrency and
* cancel all jobs when this scope completes.
*/
fun start(parentScope: CoroutineScope): Synchronizer
/**
* Stop this synchronizer. Implementations should ensure that calling this method cancels all jobs that were created
* by this instance.
* Stop this synchronizer. Implementations should ensure that calling this method cancels all
* jobs that were created by this instance.
*/
fun stop()
//
// Channels
// Flows
//
/**
* A stream of balance values, separately reflecting both the available and total balance.
*/
fun balances(): ReceiveChannel<Wallet.WalletBalance>
/**
* A stream of progress values, typically corresponding to this Synchronizer downloading blocks. Typically, 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.
*/
fun progress(): ReceiveChannel<Int>
/**
* A stream of all the outbound pending transaction that have been sent but are awaiting confirmations.
*/
fun pendingTransactions(): ReceiveChannel<List<PendingTransaction>>
/**
* 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.
*/
fun clearedTransactions(): ReceiveChannel<List<ClearedTransaction>>
/**
* 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]
*/
fun lastPending(): List<PendingTransaction>
/**
* 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]
*/
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
//
// Status
//
/* Status */
/**
* A flow of values representing the [Status] of this Synchronizer. As the status changes, a new
@ -90,6 +46,46 @@ interface Synchronizer {
*/
val status: Flow<Status>
/**
* A flow of progress values, typically corresponding to this Synchronizer downloading blocks.
* Typically, 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.
*/
val progress: Flow<Int>
/**
* A stream of balance values, separately reflecting both the available and total balance.
*/
val balances: Flow<WalletBalance>
/* Transactions */
/**
* All transactions of every type.
*/
val allTransactions: Flow<PagedList<Transaction>>
/**
* A flow of all the outbound pending transaction that have been sent but are awaiting
* confirmations.
*/
val pendingTransactions: Flow<PagedList<PendingTransaction>>
/**
* A flow of all the transactions that are on the blockchain.
*/
val clearedTransactions: Flow<PagedList<ConfirmedTransaction>>
/**
* A flow of all transactions related to sending funds.
*/
val sentTransactions: Flow<PagedList<ConfirmedTransaction>>
/**
* A flow of all transactions related to receiving funds.
*/
val receivedTransactions: Flow<PagedList<ConfirmedTransaction>>
//
// Operations
@ -98,33 +94,36 @@ interface Synchronizer {
/**
* Gets the address for the given account.
*
* @param accountId the optional accountId whose address is of interest. By default, the first account is used.
* @param accountId the optional accountId whose address is of interest. By default, the first
* account is used.
*/
suspend fun getAddress(accountId: Int = 0): String
/**
* Sends zatoshi.
*
* @param spendingKey the key that allows spends to occur.
* @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.
*/
suspend fun sendToAddress(
fun sendToAddress(
spendingKey: String,
zatoshi: Long,
toAddress: String,
memo: String = "",
fromAccountId: Int = 0
): PendingTransaction
fromAccountIndex: Int = 0
): Flow<PendingTransaction>
/**
* Attempts to cancel a previously sent transaction. Typically, cancellation is only an option if the transaction
* has not yet been submitted to the server.
* Attempts to cancel a transaction that is about to be sent. Typically, cancellation is only
* an option if the transaction has not yet been submitted to the server.
*
* @param transaction the transaction to cancel.
* @return true when the cancellation request was successful. False when it is too late to cancel.
* @return true when the cancellation request was successful. False when it is too late.
*/
fun cancelSend(transaction: SentTransaction): Boolean
suspend fun cancelSpend(transaction: PendingTransaction): Boolean
//
@ -132,28 +131,30 @@ interface Synchronizer {
//
/**
* Gets or sets a global error handler. This is a useful hook for handling unexpected critical errors.
* 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].
* @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.
* 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].
* @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.
* 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].
* @return true when the error has been handled and the sender should attempt to resend. False
* when the error is unrecoverable and the sender should [stop].
*/
var onSubmissionErrorHandler: ((Throwable?) -> Boolean)?

View File

@ -13,7 +13,8 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
class CompactBlockDbStore(
applicationContext: Context
applicationContext: Context,
val dbPath: String
) : CompactBlockStore {
private val cacheDao: CompactBlockDao
@ -25,7 +26,7 @@ class CompactBlockDbStore(
}
private fun createCompactBlockCacheDb(applicationContext: Context): CompactBlockDb {
return Room.databaseBuilder(applicationContext, CompactBlockDb::class.java, DB_CACHE_NAME)
return Room.databaseBuilder(applicationContext, CompactBlockDb::class.java, dbPath)
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
// this is a simple cache of blocks. destroying the db should be benign
.fallbackToDestructiveMigration()

View File

@ -23,7 +23,7 @@ open class CompactBlockDownloader(
compactBlockStore.write(result)
}
suspend fun rewindTo(height: Int) = withContext(IO) {
suspend fun rewindToHeight(height: Int) = withContext(IO) {
// TODO: cancel anything in flight
compactBlockStore.rewindTo(height)
}

View File

@ -3,10 +3,9 @@ package cash.z.wallet.sdk.block
import androidx.annotation.VisibleForTesting
import cash.z.wallet.sdk.annotation.OpenForTesting
import cash.z.wallet.sdk.block.CompactBlockProcessor.State.*
import cash.z.wallet.sdk.transaction.TransactionRepository
import cash.z.wallet.sdk.ext.Twig
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.exception.CompactBlockProcessorException
import cash.z.wallet.sdk.exception.RustLayerException
import cash.z.wallet.sdk.ext.*
import cash.z.wallet.sdk.ext.ZcashSdk.DOWNLOAD_BATCH_SIZE
import cash.z.wallet.sdk.ext.ZcashSdk.MAX_BACKOFF_INTERVAL
import cash.z.wallet.sdk.ext.ZcashSdk.MAX_REORG_SIZE
@ -14,9 +13,9 @@ import cash.z.wallet.sdk.ext.ZcashSdk.POLL_INTERVAL
import cash.z.wallet.sdk.ext.ZcashSdk.RETRIES
import cash.z.wallet.sdk.ext.ZcashSdk.REWIND_DISTANCE
import cash.z.wallet.sdk.ext.ZcashSdk.SAPLING_ACTIVATION_HEIGHT
import cash.z.wallet.sdk.ext.retryUpTo
import cash.z.wallet.sdk.ext.retryWithBackoff
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.jni.RustBackendWelding
import cash.z.wallet.sdk.transaction.TransactionRepository
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.channels.ReceiveChannel
@ -43,14 +42,14 @@ class CompactBlockProcessor(
) {
var onErrorListener: ((Throwable) -> Boolean)? = null
private val progressChannel = ConflatedBroadcastChannel(0)
private val consecutiveChainErrors = AtomicInteger(0)
private val lowerBoundHeight: Int = max(SAPLING_ACTIVATION_HEIGHT, minimumHeight - MAX_REORG_SIZE)
private val _state: ConflatedBroadcastChannel<State> = ConflatedBroadcastChannel(Initialized)
val state = _state.asFlow()
private val _progress = ConflatedBroadcastChannel(0)
fun progress(): ReceiveChannel<Int> = progressChannel.openSubscription()
val state = _state.asFlow()
val progress = _progress.asFlow()
/**
* Download compact blocks, verify and scan them.
@ -156,18 +155,18 @@ class CompactBlockProcessor(
for (i in 1..batches) {
retryUpTo(RETRIES) {
val end = min(range.first + (i * DOWNLOAD_BATCH_SIZE), range.last + 1)
twig("downloaded $downloadedBlockHeight..${(end - 1)} (batch $i of $batches)") {
twig("downloaded $downloadedBlockHeight..${(end - 1)} (batch $i of $batches) into : ${(rustBackend as RustBackend).dbCachePath}") {
downloader.downloadBlockRange(downloadedBlockHeight until end)
}
progress = (i / batches.toFloat() * 100).roundToInt()
// only report during large downloads. TODO: allow for configuration of "large"
progressChannel.send(progress)
_progress.send(progress)
downloadedBlockHeight = end
}
}
Twig.clip("downloading")
}
progressChannel.send(100)
_progress.send(100)
}
private fun validateNewBlocks(range: IntRange?): Int {
@ -176,7 +175,7 @@ class CompactBlockProcessor(
return -1
}
Twig.sprout("validating")
twig("validating blocks in range $range")
twig("validating blocks in range $range in db: ${(rustBackend as RustBackend).dbCachePath}")
val result = rustBackend.validateCombinedChain()
Twig.clip("validating")
return result
@ -198,7 +197,7 @@ class CompactBlockProcessor(
val lowerBound = determineLowerBound(errorHeight)
twig("handling chain error at $errorHeight by rewinding to block $lowerBound")
rustBackend.rewindToHeight(lowerBound)
downloader.rewindTo(lowerBound)
downloader.rewindToHeight(lowerBound)
}
private fun onConnectionError(throwable: Throwable): Boolean {
@ -219,18 +218,58 @@ class CompactBlockProcessor(
repository.lastScannedHeight()
}
suspend fun getAddress(accountId: Int) = withContext(IO) {
rustBackend.getAddress(accountId)
}
/**
* Calculates the latest balance info. Defaults to the first account.
*
* @param accountIndex the account to check for balance info.
*/
suspend fun getBalanceInfo(accountIndex: Int = 0): WalletBalance = withContext(IO) {
twigTask("checking balance info") {
try {
val balanceTotal = rustBackend.getBalance(accountIndex)
twig("found total balance of: $balanceTotal")
val balanceAvailable = rustBackend.getVerifiedBalance(accountIndex)
twig("found available balance of: $balanceAvailable")
WalletBalance(balanceTotal, balanceAvailable)
} catch (t: Throwable) {
twig("failed to get balance due to $t")
throw RustLayerException.BalanceException(t)
}
}
}
suspend fun setState(newState: State) {
_state.send(newState)
}
sealed class State {
interface Connected
object Downloading : Connected, State()
object Validating : Connected, State()
object Scanning : Connected, State()
interface Syncing
object Downloading : Connected, Syncing, State()
object Validating : Connected, Syncing, State()
object Scanning : Connected, Syncing, State()
object Synced : Connected, State()
object Disconnected : State()
object Stopped : State()
object Initialized : State()
}
/**
* Data structure to hold the total and available balance of the wallet. This is what is
* received on the balance channel.
*
* @param total the total balance, ignoring funds that cannot be used.
* @param available the amount of funds that are available for use. Typical reasons that funds
* may be unavailable include fairly new transactions that do not have enough confirmations or
* notes that are tied up because we are awaiting change from a transaction. When a note has
* been spent, its change cannot be used until there are enough confirmations.
*/
data class WalletBalance(
val total: Long = -1,
val available: Long = -1
)
}

View File

@ -1,6 +1,7 @@
package cash.z.wallet.sdk.db
import androidx.paging.DataSource
import androidx.paging.PositionalDataSource
import androidx.room.*
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.entity.Transaction
@ -11,7 +12,7 @@ import cash.z.wallet.sdk.entity.Transaction
@Database(
entities = [
Transaction::class,
TransactionEntity::class,
Block::class,
Received::class,
Account::class,
@ -62,16 +63,16 @@ interface TransactionDao {
fun countUnmined(): Int
@Query("SELECT * FROM transactions WHERE id_tx = :id")
fun findById(id: Long): Transaction?
fun findById(id: Long): TransactionEntity?
@Query("SELECT * FROM transactions WHERE txid = :rawTransactionId LIMIT 1")
fun findByRawId(rawTransactionId: ByteArray): Transaction?
fun findByRawId(rawTransactionId: ByteArray): TransactionEntity?
@Delete
fun delete(transaction: Transaction)
@Query("DELETE FROM transactions WHERE id_tx = :id")
fun deleteById(id: Long)
// @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.
@ -98,12 +99,12 @@ interface TransactionDao {
ORDER BY block IS NOT NULL, height DESC, time DESC, txid DESC
LIMIT :limit
""")
fun getSentTransactions(limit: Int = Int.MAX_VALUE): DataSource.Factory<Int, SentTransaction>
fun getSentTransactions(limit: Int = Int.MAX_VALUE): DataSource.Factory<Int, ConfirmedTransaction>
/**
* 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 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,
@ -123,6 +124,44 @@ interface TransactionDao {
ORDER BY minedheight DESC, blocktimeinseconds DESC, id DESC
LIMIT :limit
""")
fun getReceivedTransactions(limit: Int = Int.MAX_VALUE): DataSource.Factory<Int, ReceivedTransaction>
fun getReceivedTransactions(limit: Int = Int.MAX_VALUE): DataSource.Factory<Int, ConfirmedTransaction>
@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,
CASE
WHEN transactions.raw IS NOT NULL THEN sent_notes.value
ELSE received_notes.value
end AS value,
CASE
WHEN transactions.raw IS NOT NULL THEN sent_notes.memo
ELSE received_notes.memo
end AS memo,
CASE
WHEN transactions.raw IS NOT NULL THEN sent_notes.id_note
ELSE received_notes.id_note
end AS noteId,
blocks.time AS blockTimeInSeconds
FROM transactions
LEFT JOIN received_notes
ON transactions.id_tx = received_notes.tx
LEFT JOIN sent_notes
ON transactions.id_tx = sent_notes.tx
LEFT JOIN blocks
ON transactions.block = blocks.height
WHERE ( transactions.raw IS NULL
AND received_notes.is_change != 1 )
OR ( transactions.raw IS NOT NULL )
ORDER BY ( minedheight IS NOT NULL ),
minedheight DESC,
blocktimeinseconds DESC,
id DESC
LIMIT :limit
""")
fun getAllTransactions(limit: Int = Int.MAX_VALUE): DataSource.Factory<Int, ConfirmedTransaction>
}

View File

@ -1,6 +1,7 @@
package cash.z.wallet.sdk.db
import androidx.room.*
import cash.z.wallet.sdk.entity.PendingTransactionEntity
import cash.z.wallet.sdk.entity.PendingTransaction
@ -10,7 +11,7 @@ import cash.z.wallet.sdk.entity.PendingTransaction
@Database(
entities = [
PendingTransaction::class
PendingTransactionEntity::class
],
version = 1,
exportSchema = false
@ -26,14 +27,23 @@ abstract class PendingTransactionDb : RoomDatabase() {
@Dao
interface PendingTransactionDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(transaction: PendingTransaction): Long
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun create(transaction: PendingTransactionEntity): Long
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun update(transaction: PendingTransactionEntity)
@Delete
fun delete(transaction: PendingTransaction)
suspend fun delete(transaction: PendingTransactionEntity)
@Query("SELECT * from pending_transactions ORDER BY createTime")
fun getAll(): List<PendingTransaction>
@Query("UPDATE pending_transactions SET cancelled = 1 WHERE id = :id")
suspend fun cancel(id: Long)
@Query("SELECT * FROM pending_transactions WHERE id = :id")
suspend fun findById(id: Long): PendingTransactionEntity?
@Query("SELECT * FROM pending_transactions ORDER BY createTime")
suspend fun getAll(): List<PendingTransactionEntity>
}

View File

@ -8,7 +8,7 @@ import androidx.room.ForeignKey
tableName = "received_notes",
primaryKeys = ["id_note"],
foreignKeys = [ForeignKey(
entity = Transaction::class,
entity = TransactionEntity::class,
parentColumns = ["id_tx"],
childColumns = ["tx"]
), ForeignKey(
@ -16,7 +16,7 @@ import androidx.room.ForeignKey
parentColumns = ["account"],
childColumns = ["account"]
), ForeignKey(
entity = Transaction::class,
entity = TransactionEntity::class,
parentColumns = ["id_tx"],
childColumns = ["spent"]
)]

View File

@ -8,7 +8,7 @@ import androidx.room.ForeignKey
tableName = "sent_notes",
primaryKeys = ["id_note"],
foreignKeys = [ForeignKey(
entity = Transaction::class,
entity = TransactionEntity::class,
parentColumns = ["id_tx"],
childColumns = ["tx"]
), ForeignKey(

View File

@ -4,7 +4,6 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import cash.z.wallet.sdk.transaction.SignedTransaction
//
@ -19,7 +18,7 @@ import cash.z.wallet.sdk.transaction.SignedTransaction
childColumns = ["block"]
)]
)
data class Transaction(
data class TransactionEntity(
@ColumnInfo(name = "id_tx")
val id: Long?,
@ -42,7 +41,7 @@ data class Transaction(
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Transaction) return false
if (other !is TransactionEntity) return false
if (id != other.id) return false
if (!transactionId.contentEquals(other.transactionId)) return false
@ -68,40 +67,44 @@ data class Transaction(
result = 31 * result + (raw?.contentHashCode() ?: 0)
return result
}
}
@Entity(tableName = "pending_transactions")
data class PendingTransaction(
data class PendingTransactionEntity(
@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,
override val id: Long = 0,
override val toAddress: String = "",
override val value: Long = -1,
override val memo: String? = null,
override val accountIndex: Int,
override val minedHeight: Int = -1,
override 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(),
override val cancelled: Int = 0,
override val encodeAttempts: Int = -1,
override val submitAttempts: Int = -1,
override val errorMessage: String? = null,
override val errorCode: Int? = null,
override 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 val rawTransactionId: ByteArray? = null
) : PendingTransaction {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PendingTransaction) return false
if (other !is PendingTransactionEntity) 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 (accountIndex != other.accountIndex) return false
if (minedHeight != other.minedHeight) return false
if (expiryHeight != other.expiryHeight) return false
if (cancelled != other.cancelled) return false
if (encodeAttempts != other.encodeAttempts) return false
if (submitAttempts != other.submitAttempts) return false
if (errorMessage != other.errorMessage) return false
@ -120,9 +123,11 @@ data class PendingTransaction(
var result = id.hashCode()
result = 31 * result + toAddress.hashCode()
result = 31 * result + value.hashCode()
result = 31 * result + memo.hashCode()
result = 31 * result + (memo?.hashCode() ?: 0)
result = 31 * result + accountIndex
result = 31 * result + minedHeight
result = 31 * result + expiryHeight
result = 31 * result + cancelled
result = 31 * result + encodeAttempts
result = 31 * result + submitAttempts
result = 31 * result + (errorMessage?.hashCode() ?: 0)
@ -139,94 +144,47 @@ data class PendingTransaction(
// 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.
* A mined, shielded transaction. Since this is a [MinedTransaction], 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(
data class ConfirmedTransaction(
override val id: Long = 0L,
override val value: Long = 0L,
override val memo: ByteArray? = null,
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 {
// properties that differ from received transactions
val toAddress: String? = null,
val expiryHeight: Int? = null,
override val raw: ByteArray? = byteArrayOf()
) : MinedTransaction, SignedTransaction {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SentTransaction) return false
if (other !is ConfirmedTransaction) return false
if (id != other.id) return false
if (value != other.value) return false
if (memo != null) {
if (other.memo == null) return false
if (!memo.contentEquals(other.memo)) return false
} else if (other.memo != null) 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
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
}
@ -234,20 +192,21 @@ data class SentTransaction(
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + value.hashCode()
result = 31 * result + (memo?.contentHashCode() ?: 0)
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()
result = 31 * result + (toAddress?.hashCode() ?: 0)
result = 31 * result + (expiryHeight ?: 0)
result = 31 * result + (raw?.contentHashCode() ?: 0)
return result
}
}
data class EncodedTransaction(val txId: ByteArray, val raw: ByteArray) {
data class EncodedTransaction(val txId: ByteArray, override val raw: ByteArray) :
SignedTransaction {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is EncodedTransaction) return false
@ -265,34 +224,92 @@ data class EncodedTransaction(val txId: ByteArray, val raw: ByteArray) {
}
}
//
// Transaction Interfaces
//
/**
* Common interface between confirmed transactions on the blockchain and pending transactions being
* constructed.
*/
interface Transaction {
val id: Long
val value: Long
val memo: ByteArray?
}
/**
* Interface for anything that's able to provide signed transaction bytes.
*/
interface SignedTransaction {
val raw: ByteArray?
}
/**
* 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 MinedTransaction : Transaction {
val minedHeight: Int
val noteId: Long
val blockTimeInSeconds: Long
val transactionIndex: Int
val rawTransactionId: ByteArray
}
interface PendingTransaction : SignedTransaction, Transaction {
override val id: Long
override val value: Long
override val memo: ByteArray?
val toAddress: String
val accountIndex: Int
val minedHeight: Int
val expiryHeight: Int
val cancelled: Int
val encodeAttempts: Int
val submitAttempts: Int
val errorMessage: String?
val errorCode: Int?
val createTime: Long
val rawTransactionId: ByteArray?
}
//
// Extension-oriented design
//
fun PendingTransaction.isSameTxId(other: ClearedTransaction): Boolean {
return rawTransactionId != null && other.rawTransactionId != null && rawTransactionId.contentEquals(other.rawTransactionId)
fun PendingTransaction.isSameTxId(other: MinedTransaction): 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)
return rawTransactionId != null && other.rawTransactionId != null
&& rawTransactionId!!.contentEquals(other.rawTransactionId!!)
}
fun PendingTransaction.isCreating(): Boolean {
return raw.isEmpty() && submitAttempts <= 0 && !isFailedSubmit() && !isFailedEncoding()
return (raw?.isEmpty() != false) && submitAttempts <= 0 && !isFailedSubmit() && !isFailedEncoding()
}
fun PendingTransaction.isFailedEncoding(): Boolean {
return raw.isEmpty() && encodeAttempts > 0
return (raw?.isEmpty() != false) && encodeAttempts > 0
}
fun PendingTransaction.isFailedSubmit(): Boolean {
return errorMessage != null || (errorCode != null && errorCode < 0)
return errorMessage != null || (errorCode != null && errorCode!! < 0)
}
fun PendingTransaction.isFailure(): Boolean {
return isFailedEncoding() || isFailedSubmit()
}
fun PendingTransaction.isCancelled(): Boolean {
return cancelled > 0
}
fun PendingTransaction.isMined(): Boolean {
return minedHeight > 0
}
@ -303,9 +320,10 @@ fun PendingTransaction.isSubmitted(): Boolean {
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
return !isSubmitSuccess() && minedHeight == -1
&& (expiryHeight == -1 || expiryHeight > currentHeight) && raw != null
}
fun PendingTransaction.isSubmitSuccess(): Boolean {
return submitAttempts > 0 && (errorCode != null && errorCode >= 0) && errorMessage == null
return submitAttempts > 0 && (errorCode != null && errorCode!! >= 0) && errorMessage == null
}

View File

@ -1,27 +1,36 @@
package cash.z.wallet.sdk.exception
import java.lang.RuntimeException
/**
* Marker for all custom exceptions from the SDK. Making it an interface would result in more typing
* so its a supertype, instead.
*/
open class SdkException(message: String, cause: Throwable?) : RuntimeException(message, cause)
/**
* Exceptions thrown in the Rust layer of the SDK. We may not always be able to surface details about this
* exception so it's important for the SDK to provide helpful messages whenever these errors are encountered.
*/
sealed class RustLayerException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
sealed class RustLayerException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
class BalanceException(cause: Throwable) : RustLayerException("Error while requesting the current balance over " +
"JNI. This might mean that the database has been corrupted and needs to be rebuilt. Verify that " +
"blocks are not missing or have not been scanned out of order.", cause)
}
sealed class RepositoryException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
sealed class RepositoryException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object FalseStart: RepositoryException( "The channel is closed. Note that once a repository has stopped it " +
"cannot be restarted. Verify that the repository is not being restarted.")
}
sealed class SynchronizerException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
sealed class SynchronizerException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object FalseStart: SynchronizerException("This synchronizer was already started. Multiple calls to start are not" +
"allowed and once a synchronizer has stopped it cannot be restarted."
)
}
sealed class CompactBlockProcessorException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
sealed class CompactBlockProcessorException(message: String, cause: Throwable? = null) : SdkException(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)
@ -34,44 +43,61 @@ sealed class CompactBlockProcessorException(message: String, cause: Throwable? =
"likely means a block is missing or a reorg was mishandled. See Rust logs for details.")
}
sealed class CompactBlockStreamException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
sealed class CompactBlockStreamException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object ConnectionClosed: CompactBlockStreamException("Cannot start stream when connection is closed.")
class FalseStart(cause: Throwable?): CompactBlockStreamException("Failed to start compact block stream due to " +
"$cause caused by ${cause?.cause}")
}
sealed class WalletException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
class MissingBirthdayFilesException(directory: String) : WalletException(
sealed class BirthdayException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object UninitializedBirthdayException : BirthdayException("Error the birthday cannot be" +
" accessed before it is initialized. Verify that the new, import or open functions" +
" have been called on the initializer."
)
class MissingBirthdayFilesException(directory: String) : BirthdayException(
"Cannot initialize wallet because no birthday files were found in the $directory directory."
)
class FetchParamsException(message: String) : WalletException("Failed to fetch params due to: $message")
object MissingParamsException : WalletException(
"Cannot send funds due to missing spend or output params and attempting to download them failed."
class MissingBirthdayException(val alias: String) : BirthdayException(
"Failed to initialize wallet with alias=$alias because its birthday could not be found." +
" Verify the alias or perhaps a new wallet should be created, instead."
)
class BirthdayNotFoundException(directory: String, height: Int?) : WalletException(
class BirthdayFileNotFoundException(directory: String, height: Int?) : BirthdayException(
"Unable to find birthday file for $height verify that $directory/$height.json exists."
)
class MalformattedBirthdayFilesException(directory: String, file: String) : WalletException(
class MalformattedBirthdayFilesException(directory: String, file: String) : BirthdayException(
"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)
}
sealed class LightwalletException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) {
sealed class InitializerException(message: String, cause: Throwable? = null) : SdkException(message, cause){
class FalseStart(cause: Throwable?) : InitializerException("Failed to initialize accounts due to: $cause", cause)
class AlreadyInitializedException(cause: Throwable) : InitializerException("Failed to initialize the blocks table" +
" because it already exists.", cause)
object DatabasePathException :
InitializerException("Critical failure to locate path for storing databases. Perhaps this" +
" device prevents apps from storing data? We cannot manage initialize the wallet" +
" unless we can store data.")
}
sealed class LightwalletException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object InsecureConnection : LightwalletException("Error: attempted to connect to lightwalletd" +
" with an insecure connection! Plaintext connections are only allowed when the" +
" resource value for 'R.bool.lightwalletd_allow_very_insecure_connections' is true" +
" because this choice should be explicit.")
}
class 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.")
sealed class TransactionEncoderException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
class FetchParamsException(message: String) : TransactionEncoderException("Failed to fetch params due to: $message")
object MissingParamsException : TransactionEncoderException(
"Cannot send funds due to missing spend or output params and attempting to download them failed."
)
class TransactionNotFoundException(transactionId: Long) : TransactionEncoderException("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) : TransactionEncoderException("The transaction returned by the wallet," +
" with id $transactionId, does not have any raw data. This is a scenario where the wallet should have thrown" +
" an exception but failed to do so.")
}

View File

@ -0,0 +1,37 @@
package cash.z.wallet.sdk.ext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/**
* Utility for removing some of the boilerplate around Synchronizers and working with flows. Allows
* for collecting all the elements of a flow with a given scope this is useful when you want to
* launch multiple things in the same scope at once. Intuitively a developer may try:
* ```
* scope.launch {
* flow1.collect { collectingFunction1() }
* flow2.collect { collectingFunction2() }
* }
* ```
* But this results in the collections running sequentially rather than in parallel. Alternatively,
* This code produces the desired behavior but is verbose and a little unclear:
* ```
* scope.launch { flow1.collect { collectingFunction1() } }
* scope.launch { flow1.collect { collectingFunction2() } }
* ```
* This extension functions makes the intended behavior a little easier to read by focusing on the
* flow itself rather than the scope:
* ```
* flow1.collectWith(scope, ::collectingFunction1)
* flow2.collectWith(scope, ::collectingFunction2)
* ```
*/
fun <T> Flow<T>.collectWith(scope: CoroutineScope, block: (T) -> Unit) {
scope.launch {
collect {
block(it)
}
}
}

View File

@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.provider.Settings
import cash.z.wallet.sdk.secure.Wallet
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
@ -63,12 +62,12 @@ class BlockingProvider<T>(var value: T, val delay: Long = 5000L) : ReadWriteProp
this.value = value
}
}
@Deprecated(message = InsecureWarning.message)
class SampleKeyManager(val sampleSeed: ByteArray) : Wallet.KeyManager {
override lateinit var key: String
override val seed: ByteArray get() = sampleSeed
}
//
//@Deprecated(message = InsecureWarning.message)
//class SampleKeyManager(val sampleSeed: ByteArray) : Wallet.KeyManager {
// override lateinit var key: String
// override val seed: ByteArray get() = sampleSeed
//}
/**

View File

@ -0,0 +1,39 @@
package cash.z.wallet.sdk.ext
import android.content.Context
import android.content.SharedPreferences
import cash.z.wallet.sdk.BuildConfig
fun SharedPrefs(context: Context, name: String = "prefs"): SharedPreferences {
val fileName = "${BuildConfig.FLAVOR}.${BuildConfig.BUILD_TYPE}.$name".toLowerCase()
return context.getSharedPreferences(fileName, Context.MODE_PRIVATE)!!
}
inline fun SharedPreferences.edit(block: (SharedPreferences.Editor) -> Unit) {
edit().run {
block(this)
apply()
}
}
operator fun SharedPreferences.set(key: String, value: Any?) {
when (value) {
is String? -> edit { it.putString(key, value) }
is Int -> edit { it.putInt(key, value) }
is Boolean -> edit { it.putBoolean(key, value) }
is Float -> edit { it.putFloat(key, value) }
is Long -> edit { it.putLong(key, value) }
else -> throw UnsupportedOperationException("Not yet implemented")
}
}
inline operator fun <reified T : Any> SharedPreferences.get(key: String, defaultValue: T? = null): T? {
return when (T::class) {
String::class -> getString(key, defaultValue as? String) as T?
Int::class -> getInt(key, defaultValue as? Int ?: -1) as T?
Boolean::class -> getBoolean(key, defaultValue as? Boolean ?: false) as T?
Float::class -> getFloat(key, defaultValue as? Float ?: -1f) as T?
Long::class -> getLong(key, defaultValue as? Long ?: -1) as T?
else -> throw UnsupportedOperationException("Not yet implemented")
}
}

View File

@ -31,7 +31,7 @@ object ZcashSdk {
/**
* 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.
* by the rust backend but it is helpful to know what it is set to and should be kept in sync.
*/
const val EXPIRY_OFFSET = 20
@ -68,8 +68,9 @@ object ZcashSdk {
*/
const val LIGHTWALLETD_PORT = 9067
const val DB_DATA_NAME = "transactionData.db"
const val DB_CACHE_NAME = "compactBlockCache.db"
const val DB_DATA_NAME = "Data.db"
const val DB_CACHE_NAME = "Cache.db"
const val DEFAULT_DB_NAME_PREFIX = "ZcashSdk_"
/**
* File name for the sappling spend params
@ -81,4 +82,11 @@ object ZcashSdk {
*/
const val OUTPUT_PARAM_FILE_NAME = "sapling-output.params"
/**
* The Url that is used by default in zcashd.
* We'll want to make this externally configurable, rather than baking it into the SDK but
* this will do for now, since we're using a cloudfront URL that already redirects.
*/
const val CLOUD_PARAM_DIR_URL = "https://z.cash/downloads/"
}

View File

@ -0,0 +1,31 @@
package cash.z.wallet.sdk.ext.android
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
/* Adapted from ComputableLiveData */
abstract class ComputableFlow<T>(dispatcher: CoroutineDispatcher = Dispatchers.IO) {
private val computationScope: CoroutineScope = CoroutineScope(dispatcher + SupervisorJob())
private val computationChannel: ConflatedBroadcastChannel<T> = ConflatedBroadcastChannel()
internal val flow = computationChannel.asFlow().flowOn(dispatcher).onStart {
invalidate()
}
/**
* Invalidates the flow.
* This will trigger a call to [.compute].
*/
fun invalidate() {
computationScope.launch { computationChannel.send(compute()) }
}
fun cancel() {
computationScope.cancel()
computationChannel.cancel()
}
protected abstract fun compute(): T
}

View File

@ -0,0 +1,84 @@
package cash.z.wallet.sdk.ext.android
import android.annotation.SuppressLint
import androidx.paging.Config
import androidx.paging.DataSource
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
/* Adapted from LiveDataPagedList */
/**
* Constructs a `Flow<PagedList>`, from this `DataSource.Factory`, convenience for
* [FlowPagedListBuilder].
*
* No work (such as loading) is done immediately, the creation of the first PagedList is is
* deferred until the Flow is collected.
*
* @param config Paging configuration.
* @param initialLoadKey Initial load key passed to the first PagedList/DataSource.
* @param boundaryCallback The boundary callback for listening to PagedList load state.
* @param fetchExecutor Executor for fetching data from DataSources.
*
* @see LivePagedListBuilder
*/
@SuppressLint("RestrictedApi")
fun <Key, Value> DataSource.Factory<Key, Value>.toFlowPagedList(
config: PagedList.Config,
initialLoadKey: Key? = null,
boundaryCallback: PagedList.BoundaryCallback<Value>? = null,
fetchContext: CoroutineDispatcher = Dispatchers.IO
): Flow<PagedList<Value>> =
FlowPagedListBuilder(this, config, initialLoadKey, boundaryCallback, fetchContext)
.build()
/**
* Constructs a `Flow<PagedList>`, from this `DataSource.Factory`, convenience for
* [FlowPagedListBuilder].
*
* No work (such as loading) is done immediately, the creation of the first PagedList is is
* deferred until the Flow is collected.
*
* @param pageSize Page size.
* @param initialLoadKey Initial load key passed to the first PagedList/DataSource.
* @param boundaryCallback The boundary callback for listening to PagedList load state.
* @param fetchExecutor Executor for fetching data from DataSources.
*
* @see FlowPagedListBuilder
*/
@SuppressLint("RestrictedApi")
inline fun <Key, Value> DataSource.Factory<Key, Value>.toFlowPagedList(
pageSize: Int,
initialLoadKey: Key? = null,
boundaryCallback: PagedList.BoundaryCallback<Value>? = null,
fetchContext: CoroutineDispatcher = Dispatchers.IO
): Flow<PagedList<Value>> =
FlowPagedListBuilder(this, Config(pageSize), initialLoadKey, boundaryCallback, fetchContext)
.build()
/**
* Allows another component to call invalidate on the most recently created datasource. Although it
* is expected that a DataSource will invalidate itself, there are times where external components
* have modified the underlying data and thereby become responsible for invalidation. In our case,
* there is more than one process updating the database. So the other process must invalidate the
* data after an update in order to trigger refreshes all the way up the stack.
*/
fun <Key, Value> DataSource.Factory<Key, Value>.toRefreshable(): RefreshableDataSourceFactory<Key, Value> =
RefreshableDataSourceFactory(this)
class RefreshableDataSourceFactory<Key, Value>(private val delegate: DataSource.Factory<Key, Value>) :
DataSource.Factory<Key, Value>() {
private var lastDataSource: DataSource<Key, Value>? = null
override fun create(): DataSource<Key, Value> {
refresh()
return delegate.create().also { lastDataSource = it }
}
fun refresh() {
lastDataSource?.invalidate()
lastDataSource = null
}
}

View File

@ -0,0 +1,101 @@
package cash.z.wallet.sdk.ext.android
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import androidx.paging.Config
import androidx.paging.DataSource
import androidx.paging.PagedList
import cash.z.wallet.sdk.ext.Twig
import cash.z.wallet.sdk.ext.twig
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import java.util.concurrent.Executor
/* Adapted from LivePagedListBuilder */
class FlowPagedListBuilder<Key, Value>(
private val dataSourceFactory: DataSource.Factory<Key, Value>,
private val config: PagedList.Config,
private var initialLoadKey: Key? = null,
private var boundaryCallback: PagedList.BoundaryCallback<*>? = null,
private val notifyContext: CoroutineDispatcher = Dispatchers.Main,
private val fetchContext: CoroutineDispatcher = Dispatchers.IO
) {
/**
* Creates a FlowPagedListBuilder with required parameters.
*
* @param dataSourceFactory DataSource factory providing DataSource generations.
* @param config Paging configuration.
*/
constructor(dataSourceFactory: DataSource.Factory<Key, Value>, pageSize: Int) : this(
dataSourceFactory,
Config(pageSize)
)
/**
* Constructs the `Flow<PagedList>`.
*
* No work (such as loading) is done immediately, the creation of the first PagedList is is
* deferred until the Flow is collected.
*
* @return The Flow of PagedLists
*/
@SuppressLint("RestrictedApi")
fun build(): Flow<PagedList<Value>> {
return object : ComputableFlow<PagedList<Value>>(fetchContext) {
private lateinit var dataSource: DataSource<Key, Value>
private lateinit var list: PagedList<Value>
private val callback = DataSource.InvalidatedCallback { invalidate() }
override fun compute(): PagedList<Value> {
Twig.sprout("computing")
var initializeKey = initialLoadKey
if (::list.isInitialized) {
twig("list is initialized")
initializeKey = list.lastKey as Key
}
do {
twig("zzzzz do this while...")
if (::dataSource.isInitialized) {
dataSource.removeInvalidatedCallback(callback)
}
dataSource = dataSourceFactory.create().apply {
twig("adding an invalidated callback")
addInvalidatedCallback(callback)
}
list = PagedList.Builder(dataSource, config)
.setNotifyExecutor(notifyContext.toExecutor())
.setFetchExecutor(fetchContext.toExecutor())
.setBoundaryCallback(boundaryCallback)
.setInitialKey(initializeKey)
.build()
} while (list.isDetached)
return list.also {
Twig.clip("computing")
}
}
}.flow
}
private fun CoroutineDispatcher.toExecutor(): Executor {
return when (this) {
is ExecutorCoroutineDispatcher -> executor
is MainCoroutineDispatcher -> MainThreadExecutor()
else -> throw IllegalStateException("Unable to create executor based on dispatcher: $this")
}
}
class MainThreadExecutor : Executor {
private val handler = Handler(Looper.getMainLooper())
override fun execute(runnable: Runnable) {
handler.post(runnable)
}
}
}

View File

@ -0,0 +1,3 @@
# Android Placeholders
This package contains classes and extensions that don't yet exist in Android but are coming. Once native support is added, the equivalent functionality in this package will be removed.

View File

@ -1,9 +1,11 @@
package cash.z.wallet.sdk.jni
import android.content.Context
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.exception.BirthdayException
import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.ZcashSdk.OUTPUT_PARAM_FILE_NAME
import cash.z.wallet.sdk.ext.ZcashSdk.SPEND_PARAM_FILE_NAME
import cash.z.wallet.sdk.ext.twig
import java.io.File
/**
@ -11,27 +13,37 @@ import java.io.File
* not be called directly by code outside of the SDK. Instead, one of the higher-level components
* should be used such as Wallet.kt or CompactBlockProcessor.kt.
*/
object RustBackend : RustBackendWelding {
private var loaded = false
private lateinit var dbDataPath: String
private lateinit var dbCachePath: String
lateinit var paramDestinationDir: String
class RustBackend : RustBackendWelding {
internal lateinit var dbDataPath: String
internal lateinit var dbCachePath: String
internal lateinit var dbNamePrefix: String
internal lateinit var paramDestinationDir: String
internal var birthdayHeight: Int = -1
get() = if (field != -1) field else throw BirthdayException.UninitializedBirthdayException
fun init(appContext: Context, dbNamePrefix: String = ZcashSdk.DEFAULT_DB_NAME_PREFIX) =
init(
appContext.getDatabasePath("unused.db").parentFile.absolutePath,
"${appContext.cacheDir.absolutePath}/params",
dbNamePrefix
)
/**
* Loads the library and initializes path variables. Although it is best to only call this
* function once, it is idempotent.
*/
override fun create(appContext: Context, dbCacheName: String, dbDataName: String): RustBackend {
fun init(
dbPath: String,
paramsPath: String,
dbNamePrefix: String = ZcashSdk.DEFAULT_DB_NAME_PREFIX
): RustBackend {
this.dbNamePrefix = dbNamePrefix
twig("Creating RustBackend") {
// It is safe to call these things twice but not efficient. So we add a loose check and
// ignore the fact that it's not thread-safe.
if (!loaded) {
loadRustLibrary()
initLogs()
}
dbCachePath = appContext.getDatabasePath(dbCacheName).absolutePath
dbDataPath = appContext.getDatabasePath(dbDataName).absolutePath
paramDestinationDir = "${appContext.cacheDir.absolutePath}/params"
load()
dbCachePath = File(dbPath, "${dbNamePrefix}${ZcashSdk.DB_CACHE_NAME}").absolutePath
dbDataPath = File(dbPath, "${dbNamePrefix}${ZcashSdk.DB_DATA_NAME}").absolutePath
paramDestinationDir = paramsPath
}
return this
}
@ -42,51 +54,47 @@ object RustBackend : RustBackendWelding {
File(dbDataPath).delete()
}
/**
* The first call made to this object in order to load the Rust backend library. All other calls
* will fail if this function has not been invoked.
*/
private fun loadRustLibrary() {
try {
System.loadLibrary("zcashwalletsdk")
loaded = true
} catch (e: Throwable) {
twig("Error while loading native library: ${e.message}")
}
}
//
// Wrapper Functions
//
override fun initDataDb(): Boolean = initDataDb(dbDataPath)
override fun initDataDb() = initDataDb(dbDataPath)
override fun initAccountsTable(seed: ByteArray, numberOfAccounts: Int): Array<String> =
initAccountsTable(dbDataPath, seed, numberOfAccounts)
// override fun initAccountsTable(extfvks: Array<String>) =
// initAccountsTableWithKeys(dbDataPath, extfvks)
override fun initAccountsTable(
seed: ByteArray,
numberOfAccounts: Int
) = initAccountsTable(dbDataPath, seed, numberOfAccounts)
override fun initBlocksTable(
height: Int,
hash: String,
time: Long,
saplingTree: String
): Boolean = initBlocksTable(dbDataPath, height, hash, time, saplingTree)
): Boolean {
birthdayHeight = height
return initBlocksTable(dbDataPath, height, hash, time, saplingTree)
}
override fun getAddress(account: Int): String = getAddress(dbDataPath, account)
override fun getAddress(account: Int) = getAddress(dbDataPath, account)
override fun getBalance(account: Int): Long = getBalance(dbDataPath, account)
override fun getBalance(account: Int) = getBalance(dbDataPath, account)
override fun getVerifiedBalance(account: Int): Long = getVerifiedBalance(dbDataPath, account)
override fun getVerifiedBalance(account: Int) = getVerifiedBalance(dbDataPath, account)
override fun getReceivedMemoAsUtf8(idNote: Long): String =
override fun getReceivedMemoAsUtf8(idNote: Long) =
getReceivedMemoAsUtf8(dbDataPath, idNote)
override fun getSentMemoAsUtf8(idNote: Long): String = getSentMemoAsUtf8(dbDataPath, idNote)
override fun getSentMemoAsUtf8(idNote: Long) = getSentMemoAsUtf8(dbDataPath, idNote)
override fun validateCombinedChain(): Int = validateCombinedChain(dbCachePath, dbDataPath)
override fun validateCombinedChain() = validateCombinedChain(dbCachePath, dbDataPath)
override fun rewindToHeight(height: Int): Boolean = rewindToHeight(dbDataPath, height)
override fun rewindToHeight(height: Int) = rewindToHeight(dbDataPath, height)
override fun scanBlocks(): Boolean = scanBlocks(dbCachePath, dbDataPath)
override fun scanBlocks() = scanBlocks(dbCachePath, dbDataPath)
override fun createToAddress(
account: Int,
@ -105,46 +113,108 @@ object RustBackend : RustBackendWelding {
"${paramDestinationDir}/$OUTPUT_PARAM_FILE_NAME"
)
override fun deriveSpendingKeys(seed: ByteArray, numberOfAccounts: Int) =
deriveExtendedSpendingKeys(seed, numberOfAccounts)
//
// External Functions
//
override fun deriveViewingKeys(seed: ByteArray, numberOfAccounts: Int) =
deriveExtendedFullViewingKeys(seed, numberOfAccounts)
private external fun initDataDb(dbDataPath: String): Boolean
override fun deriveViewingKey(spendingKey: String) = deriveExtendedFullViewingKey(spendingKey)
private external fun initAccountsTable(
dbDataPath: String,
seed: ByteArray,
accounts: Int
): Array<String>
override fun isValidShieldedAddr(addr: String) =
isValidShieldedAddress(addr)
private external fun initBlocksTable(
dbDataPath: String,
height: Int,
hash: String,
time: Long,
saplingTree: String
): Boolean
override fun isValidTransparentAddr(addr: String) =
isValidTransparentAddress(addr)
private external fun getAddress(dbDataPath: String, account: Int): String
/**
* Exposes all of the librustzcash functions along with helpers for loading the static library.
*/
companion object {
private var loaded = false
external override fun isValidShieldedAddress(addr: String): Boolean
fun load() {
// It is safe to call these things twice but not efficient. So we add a loose check and
// ignore the fact that it's not thread-safe.
if (!loaded) {
twig("Loading RustBackend") {
loadRustLibrary()
initLogs()
}
}
}
external override fun isValidTransparentAddress(addr: String): Boolean
/**
* The first call made to this object in order to load the Rust backend library. All other
* external function calls will fail if the libraries have not been loaded.
*/
private fun loadRustLibrary() {
try {
System.loadLibrary("zcashwalletsdk")
loaded = true
} catch (e: Throwable) {
twig("Error while loading native library: ${e.message}")
}
}
private external fun getBalance(dbDataPath: String, account: Int): Long
private external fun getVerifiedBalance(dbDataPath: String, account: Int): Long
//
// External Functions
//
private external fun getReceivedMemoAsUtf8(dbDataPath: String, idNote: Long): String
@JvmStatic private external fun initDataDb(dbDataPath: String): Boolean
private external fun getSentMemoAsUtf8(dbDataPath: String, idNote: Long): String
@JvmStatic private external fun initAccountsTable(
dbDataPath: String,
seed: ByteArray,
accounts: Int
): Array<String>
private external fun validateCombinedChain(dbCachePath: String, dbDataPath: String): Int
// @JvmStatic private external fun initAccountsTableWithKeys(
// dbDataPath: String,
// extfvk: Array<String>
// )
private external fun rewindToHeight(dbDataPath: String, height: Int): Boolean
@JvmStatic private external fun initBlocksTable(
dbDataPath: String,
height: Int,
hash: String,
time: Long,
saplingTree: String
): Boolean
private external fun scanBlocks(dbCachePath: String, dbDataPath: String): Boolean
@JvmStatic private external fun getAddress(dbDataPath: String, account: Int): String
@JvmStatic private external fun isValidShieldedAddress(addr: String): Boolean
@JvmStatic private external fun isValidTransparentAddress(addr: String): Boolean
@JvmStatic private external fun getBalance(dbDataPath: String, account: Int): Long
@JvmStatic private external fun getVerifiedBalance(dbDataPath: String, account: Int): Long
@JvmStatic private external fun getReceivedMemoAsUtf8(dbDataPath: String, idNote: Long): String
@JvmStatic private external fun getSentMemoAsUtf8(dbDataPath: String, idNote: Long): String
@JvmStatic private external fun validateCombinedChain(dbCachePath: String, dbDataPath: String): Int
@JvmStatic private external fun rewindToHeight(dbDataPath: String, height: Int): Boolean
@JvmStatic private external fun scanBlocks(dbCachePath: String, dbDataPath: String): Boolean
@JvmStatic private external fun createToAddress(
dbDataPath: String,
account: Int,
extsk: String,
to: String,
value: Long,
memo: ByteArray,
spendParamsPath: String,
outputParamsPath: String
): Long
@JvmStatic private external fun initLogs()
private external fun createToAddress(
dbDataPath: String,
@ -157,6 +227,10 @@ object RustBackend : RustBackendWelding {
outputParamsPath: String
): Long
private external fun initLogs()
@JvmStatic private external fun deriveExtendedSpendingKeys(seed: ByteArray, numberOfAccounts: Int): Array<String>
@JvmStatic private external fun deriveExtendedFullViewingKeys(seed: ByteArray, numberOfAccounts: Int): Array<String>
@JvmStatic private external fun deriveExtendedFullViewingKey(spendingKey: String): String
}
}

View File

@ -1,31 +1,24 @@
package cash.z.wallet.sdk.jni
import android.content.Context
import cash.z.wallet.sdk.ext.ZcashSdk
/**
* Contract defining the exposed capabilities of the Rust backend.
* This is what welds the SDK to the Rust layer.
*/
interface RustBackendWelding {
fun create(
appContext: Context,
dbCacheName: String = ZcashSdk.DB_CACHE_NAME,
dbDataName: String = ZcashSdk.DB_DATA_NAME
): RustBackendWelding
fun initDataDb(): Boolean
// fun initAccountsTable(extfvks: Array<ByteArray>, numberOfAccounts: Int)
fun initAccountsTable(seed: ByteArray, numberOfAccounts: Int): Array<String>
fun initBlocksTable(height: Int, hash: String, time: Long, saplingTree: String): Boolean
fun getAddress(account: Int = 0): String
fun isValidShieldedAddress(addr: String): Boolean
fun isValidShieldedAddr(addr: String): Boolean
fun isValidTransparentAddress(addr: String): Boolean
fun isValidTransparentAddr(addr: String): Boolean
fun getBalance(account: Int = 0): Long
@ -49,4 +42,9 @@ interface RustBackendWelding {
memo: String
): Long
fun deriveSpendingKeys(seed: ByteArray, numberOfAccounts: Int = 1): Array<String>
fun deriveViewingKeys(seed: ByteArray, numberOfAccounts: Int = 1): Array<String>
fun deriveViewingKey(spendingKey: String): String
}

View File

@ -1,384 +0,0 @@
package cash.z.wallet.sdk.secure
import android.content.Context
import cash.z.wallet.sdk.ext.Bush
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.ext.twigTask
import cash.z.wallet.sdk.exception.RustLayerException
import cash.z.wallet.sdk.exception.WalletException
import cash.z.wallet.sdk.exception.WalletException.*
import cash.z.wallet.sdk.ext.ZcashSdk.DB_CACHE_NAME
import cash.z.wallet.sdk.ext.ZcashSdk.DB_DATA_NAME
import cash.z.wallet.sdk.ext.ZcashSdk.OUTPUT_PARAM_FILE_NAME
import cash.z.wallet.sdk.ext.ZcashSdk.SAPLING_ACTIVATION_HEIGHT
import cash.z.wallet.sdk.ext.ZcashSdk.SPEND_PARAM_FILE_NAME
import cash.z.wallet.sdk.ext.masked
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.jni.RustBackendWelding
import com.google.gson.Gson
import com.google.gson.stream.JsonReader
import com.squareup.okhttp.OkHttpClient
import com.squareup.okhttp.Request
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
import okio.Okio
import java.io.File
import java.io.InputStreamReader
/**
* Wrapper for all the Rust backend functionality that does not involve processing blocks. This
* class initializes the Rust backend and the supporting data required to exercise those abilities.
* The [cash.z.wallet.sdk.block.CompactBlockProcessor] handles all the remaining Rust backend
* functionality, related to processing blocks.
*/
class Wallet {
lateinit var rustBackend: RustBackendWelding
var lowerBoundHeight: Int = SAPLING_ACTIVATION_HEIGHT
fun clear() {
if (::rustBackend.isInitialized) {
(rustBackend as RustBackend).clear()
} else {
twig("WARNING: attempted to clear an uninitialized wallet. No action was taken since " +
"the database paths have not yet been set.")
}
}
/**
* Initialize the wallet with the given seed and return the related private keys for each
* account specified or null if the wallet was previously initialized and block data exists on
* disk. When this method returns null, that signals that the wallet will need to retrieve the
* private keys from its own secure storage. In other words, the private keys are only given out
* once for each set of database files. Subsequent calls to [initialize] will only load the Rust
* library and return null.
*
* 'compactBlockCache.db' and 'transactionData.db' files are created by this function (if they
* do not already exist). These files can be given a prefix for scenarios where multiple wallets
* operate in one app--for instance, when sweeping funds from another wallet seed.
*
* @param appContext the application context.
* @param seed the seed to use for initializing this wallet.
* @param birthdayHeight the height corresponding to when the wallet seed was created. If null,
* this signals that the wallet is being born.
* @param numberOfAccounts the number of accounts to create from this seed.
* @param dbFileNamePrefix the optional prefix to add to the names of the database files.
*/
fun initialize(
appContext: Context,
seed: ByteArray,
birthdayHeight: Int? = null,
numberOfAccounts: Int = 1,
dbFileNamePrefix: String = ""
): Array<String>? {
rustBackend = RustBackend.create(
appContext,
"${dbFileNamePrefix}$DB_CACHE_NAME",
"${dbFileNamePrefix}$DB_DATA_NAME"
)
try {
// only creates tables, if they don't exist
rustBackend.initDataDb()
twig("Initialized wallet for first run")
} catch (e: Throwable) {
throw WalletException.FalseStart(e)
}
try {
val birthday = loadBirthdayFromAssets(appContext, birthdayHeight)
lowerBoundHeight = birthday.height
rustBackend.initBlocksTable(
birthday.height,
birthday.hash,
birthday.time,
birthday.tree
)
twig("seeded the database with sapling tree at height ${birthday.height} (expected $birthdayHeight)")
} catch (t: Throwable) {
if (t.message?.contains("is not empty") == true) {
return null
} else {
throw WalletException.FalseStart(t)
}
}
try {
return rustBackend.initAccountsTable(seed, numberOfAccounts).also {
twig("Initialized the accounts table with $numberOfAccounts account(s)")
}
} catch (e: Throwable) {
throw WalletException.FalseStart(e)
}
}
/**
* Gets the address for this wallet, defaulting to the first account.
*/
fun getAddress(accountIndex: Int = 0): String {
return rustBackend.getAddress(accountIndex)
}
/**
* 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.
*
* @param accountIndex the account to check for balance info. Defaults to zero.
*/
fun availableBalanceSnapshot(accountIndex: Int = 0): Long {
return rustBackend.getVerifiedBalance(accountIndex)
}
/**
* Calculates the latest balance info and emits it into the balance channel. Defaults to the
* first account.
*
* @param accountIndex the account to check for balance info.
*/
suspend fun getBalanceInfo(accountIndex: Int = 0): WalletBalance = withContext(IO) {
twigTask("checking balance info") {
try {
val balanceTotal = rustBackend.getBalance(accountIndex)
twig("found total balance of: $balanceTotal")
val balanceAvailable = rustBackend.getVerifiedBalance(accountIndex)
twig("found available balance of: $balanceAvailable")
WalletBalance(balanceTotal, balanceAvailable)
} catch (t: Throwable) {
twig("failed to get balance due to $t")
throw RustLayerException.BalanceException(t)
}
}
}
/**
* Does the proofs and processing required to create a transaction to spend funds and inserts
* the result in the database. On average, this call takes over 10 seconds.
*
* @param value the zatoshi value to send
* @param toAddress the destination address
* @param memo the memo, which is not augmented in any way
*
* @return the row id in the transactions table that contains the spend transaction
* or -1 if it failed
*/
suspend fun createSpend(
spendingKey: String,
value: Long,
toAddress: String,
memo: String = "",
fromAccountIndex: Int = 0
): Long = withContext(IO) {
twigTask("creating transaction to spend $value zatoshi to" +
" ${toAddress.masked()} with memo $memo") {
try {
ensureParams((rustBackend as RustBackend).paramDestinationDir)
twig("params exist! attempting to send...")
rustBackend.createToAddress(
fromAccountIndex,
spendingKey,
toAddress,
value,
memo
)
} catch (t: Throwable) {
twig("${t.message}")
throw t
}
}.also { result ->
twig("result of sendToAddress: $result")
}
}
/**
* Download and store the params into the given directory.
*
* @param destinationDir the directory where the params will be stored. It's assumed that we
* have write access to this directory. Typically, this should be the app's cache directory
* because it is not harmful if these files are cleared by the user since they are downloaded
* on-demand.
*/
suspend fun fetchParams(destinationDir: String) = withContext(IO) {
val client = createHttpClient()
var failureMessage = ""
arrayOf(SPEND_PARAM_FILE_NAME, OUTPUT_PARAM_FILE_NAME).forEach { paramFileName ->
val url = "$CLOUD_PARAM_DIR_URL/$paramFileName"
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
twig("fetch succeeded")
val file = File(destinationDir, paramFileName)
if(file.parentFile.exists()) {
twig("directory exists!")
} else {
twig("directory did not exist attempting to make it")
file.parentFile.mkdirs()
}
Okio.buffer(Okio.sink(file)).use {
twig("writing to $file")
it.writeAll(response.body().source())
}
twig("fetch succeeded, done writing $paramFileName")
} else {
failureMessage += "Error while fetching $paramFileName : $response\n"
twig(failureMessage)
}
}
if (failureMessage.isNotEmpty()) throw WalletException.FetchParamsException(failureMessage)
}
/**
* Checks the given directory for the output and spending params and calls [fetchParams] if
* they're missing.
*
* @param destinationDir the directory where the params should be stored.
*/
private suspend fun ensureParams(destinationDir: String) {
var hadError = false
arrayOf(SPEND_PARAM_FILE_NAME, OUTPUT_PARAM_FILE_NAME).forEach { paramFileName ->
if (!File(destinationDir, paramFileName).exists()) {
twig("ERROR: $paramFileName not found at location: $destinationDir")
hadError = true
}
}
if (hadError) {
try {
Bush.trunk.twigTask("attempting to download missing params") {
fetchParams(destinationDir)
}
} catch (e: Throwable) {
twig("failed to fetch params due to: $e")
throw WalletException.MissingParamsException
}
}
}
//
// Helpers
//
/**
* Http client is only used for downloading sapling spend and output params data, which are
* necessary for the wallet to scan blocks.
*/
private fun createHttpClient(): OkHttpClient {
//TODO: add logging and timeouts
return OkHttpClient()
}
companion object {
/**
* The Url that is used by default in zcashd.
* We'll want to make this externally configurable, rather than baking it into the SDK but
* this will do for now, since we're using a cloudfront URL that already redirects.
*/
const val CLOUD_PARAM_DIR_URL = "https://z.cash/downloads/"
/**
* Directory within the assets folder where birthday data
* (i.e. sapling trees for a given height) can be found.
*/
const val BIRTHDAY_DIRECTORY = "zcash/saplingtree"
/**
* Load the given birthday file from the assets of the given context. When no height is
* specified, we default to the file with the greatest name.
*
* @param context the context from which to load assets.
* @param birthdayHeight the height file to look for among the file names.
*
* @return a WalletBirthday that reflects the contents of the file or an exception when
* parsing fails.
*/
fun loadBirthdayFromAssets(context: Context, birthdayHeight: Int? = null): WalletBirthday {
val treeFiles =
context.assets.list(BIRTHDAY_DIRECTORY)?.apply { sortDescending() }
if (treeFiles.isNullOrEmpty()) throw MissingBirthdayFilesException(BIRTHDAY_DIRECTORY)
val file: String
try {
file = treeFiles.first() {
if (birthdayHeight == null) true
else it.contains(birthdayHeight.toString())
}
} catch (t: Throwable) {
throw BirthdayNotFoundException(BIRTHDAY_DIRECTORY, birthdayHeight)
}
try {
val reader = JsonReader(
InputStreamReader(context.assets.open("$BIRTHDAY_DIRECTORY/$file"))
)
return Gson().fromJson(reader, WalletBirthday::class.java)
} catch (t: Throwable) {
throw MalformattedBirthdayFilesException(BIRTHDAY_DIRECTORY, treeFiles[0])
}
}
}
/**
* Represents the wallet's birthday which can be thought of as a checkpoint at the earliest
* moment in history where transactions related to this wallet could exist. Ideally, this would
* correspond to the latest block height at the time the wallet key was created. Worst case, the
* height of Sapling activation could be used (280000).
*
* Knowing a wallet's birthday can significantly reduce the amount of data that it needs to
* download because none of the data before that height needs to be scanned for transactions.
* However, we do need the Sapling tree data in order to construct valid transactions from that
* point forward. This birthday contains that tree data, allowing us to avoid downloading all
* the compact blocks required in order to generate it.
*
* Currently, the data for this is generated by running `cargo run --release --features=updater`
* with the SDK and saving the resulting JSON to the `src/main/assets/zcash` folder. That script
* simply builds a Sapling tree from the start of Sapling activation up to the latest block
* height. In the future, this data could be exposed as a service on the lightwalletd server
* because every zcashd node already maintains the sapling tree for each block. For now, we just
* include the latest checkpoint in each SDK release.
*
* New wallets can ignore any blocks created before their birthday.
*
* @param height the height at the time the wallet was born
* @param hash the block hash corresponding to the given height
* @param time the time the wallet was born, in seconds
* @param tree the sapling tree corresponding to the given height. This takes around 15 minutes
* of processing to generate from scratch because all blocks since activation need to be
* considered. So when it is calculated in advance it can save the user a lot of time.
*/
data class WalletBirthday(
val height: Int = -1,
val hash: String = "",
val time: Long = -1,
val tree: String = ""
)
/**
* Data structure to hold the total and available balance of the wallet. This is what is
* received on the balance channel.
*
* @param total the total balance, ignoring funds that cannot be used.
* @param available the amount of funds that are available for use. Typical reasons that funds
* may be unavailable include fairly new transactions that do not have enough confirmations or
* notes that are tied up because we are awaiting change from a transaction. When a note has
* been spent, its change cannot be used until there are enough confirmations.
*/
data class WalletBalance(
val total: Long = -1,
val available: Long = -1
)
//
// Key Management Interfaces
//
interface KeyManager: SeedProvider, SpendingKeyStore, SpendingKeyProvider
interface SeedProvider {
val seed: ByteArray
}
interface SpendingKeyStore {
var key: String
}
interface SpendingKeyProvider {
val key: String
}
}

View File

@ -4,15 +4,15 @@ import cash.z.wallet.sdk.rpc.CompactFormats
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.
* 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.
* @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<CompactFormats.CompactBlock>

View File

@ -1,20 +1,17 @@
package cash.z.wallet.sdk.transaction
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.paging.PagedList
import androidx.paging.toLiveData
import androidx.room.Room
import androidx.room.RoomDatabase
import cash.z.wallet.sdk.db.BlockDao
import cash.z.wallet.sdk.db.DerivedDataDb
import cash.z.wallet.sdk.db.TransactionDao
import cash.z.wallet.sdk.entity.ClearedTransaction
import cash.z.wallet.sdk.entity.Transaction
import cash.z.wallet.sdk.entity.TransactionEntity
import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.android.toFlowPagedList
import cash.z.wallet.sdk.ext.android.toRefreshable
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.ext.twigTask
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
@ -46,25 +43,20 @@ open class PagedTransactionRepository(
private val blocks: BlockDao = derivedDataDb.blockDao()
private val transactions: TransactionDao = derivedDataDb.transactionDao()
private val transactionLivePagedList =
transactions.getReceivedTransactions().toLiveData(pageSize)
private val receivedTxDataSourceFactory = transactions.getReceivedTransactions().toRefreshable()
private val sentTxDataSourceFactory = transactions.getSentTransactions().toRefreshable()
private val allTxDataSourceFactory = transactions.getAllTransactions().toRefreshable()
/**
* The primary function of this repository. Callers to this method receive a [PagedList]
* snapshot of the current data source that can then be queried by page, via the normal [List]
* API. Meaning, the details of the paging behavior, including caching and pre-fetch are handled
* automatically. This integrates directly with the RecyclerView for seamless display of a large
* number of transactions.
*/
fun setTransactionPageListener(
lifecycleOwner: LifecycleOwner,
listener: (PagedList<out ClearedTransaction>) -> Unit
) {
transactionLivePagedList.removeObservers(lifecycleOwner)
transactionLivePagedList.observe(lifecycleOwner, Observer { transactions ->
listener(transactions)
})
}
//
// TransactionRepository API
//
override val receivedTransactions = receivedTxDataSourceFactory.toFlowPagedList(pageSize)
override val sentTransactions = sentTxDataSourceFactory.toFlowPagedList(pageSize)
override val allTransactions = allTxDataSourceFactory.toFlowPagedList(pageSize)
override fun invalidate() = receivedTxDataSourceFactory.refresh()
override fun lastScannedHeight(): Int {
return blocks.lastScannedHeight()
@ -74,23 +66,17 @@ open class PagedTransactionRepository(
return blocks.count() > 0
}
override suspend fun findTransactionById(txId: Long): Transaction? = withContext(IO) {
override suspend fun findTransactionById(txId: Long): TransactionEntity? = withContext(IO) {
twig("finding transaction with id $txId on thread ${Thread.currentThread().name}")
val transaction = transactions.findById(txId)
twig("found ${transaction?.id}")
transaction
}
override suspend fun findTransactionByRawId(rawTxId: ByteArray): Transaction? = withContext(IO) {
override suspend fun findTransactionByRawId(rawTxId: ByteArray): TransactionEntity? = withContext(IO) {
transactions.findByRawId(rawTxId)
}
override suspend fun deleteTransactionById(txId: Long) = withContext(IO) {
twigTask("deleting transaction with id $txId") {
transactions.deleteById(txId)
}
}
fun close() {
derivedDataDb.close()
}

View File

@ -5,149 +5,195 @@ 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.ZcashSdk.EXPIRY_OFFSET
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.service.LightWalletService
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.math.max
/**
* 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 {
class PersistentTransactionManager(
db: PendingTransactionDb,
private val encoder: TransactionEncoder,
private val service: LightWalletService
) : OutboundTransactionManager {
private val dao: PendingTransactionDao = db.pendingTransactionDao()
private val daoMutex = Mutex()
/**
* Internal reference to the dao that is only accessed after locking the [daoMutex] in order
* to enforce DB access in both a threadsafe and coroutinesafe way.
*/
private val _dao: PendingTransactionDao = db.pendingTransactionDao()
/**
* Constructor that creates the database and then executes a callback on it.
*/
constructor(
appContext: Context,
encoder: TransactionEncoder,
service: LightWalletService,
dataDbName: String = "PendingTransactions.db"
) : this(
Room.databaseBuilder(
appContext,
PendingTransactionDb::class.java,
dataDbName
).setJournalMode(RoomDatabase.JournalMode.TRUNCATE).build()
).setJournalMode(RoomDatabase.JournalMode.TRUNCATE).build(),
encoder,
service
)
override fun start() {
twig("TransactionManager starting")
}
override fun stop() {
twig("TransactionManager stopping")
db.close()
}
suspend fun initPlaceholder(
/**
* Initialize a [PendingTransaction] and then insert it in the database for monitoring and
* follow-up.
*/
override fun initSpend(
zatoshiValue: Long,
toAddress: String,
memo: String
): PendingTransaction? = withContext(IO) {
memo: String,
fromAccountIndex: Int
): Flow<PendingTransaction> = flow {
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("submitting transaction to lightwalletd - memo: ${tx.memo} amount: ${tx.value}")
val response = service.submitTransaction(pendingTransaction.raw!!)
val error = response.errorCode < 0
twig("${if (error) "FAILURE! " else "SUCCESS!"} submit transaction completed with response: ${response.errorCode}: ${response.errorMessage}")
tx = tx.copy(errorMessage = if (error) response.errorMessage else 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,
var tx = PendingTransactionEntity(
value = zatoshiValue,
toAddress = toAddress,
memo = memo,
expiryHeight = if (currentHeight == -1) -1 else currentHeight + EXPIRY_OFFSET
accountIndex = fromAccountIndex
)
try {
twig("creating tx in DB: $tx")
pendingTransactionDao {
val insertedTx = findById(create(tx))
twig("pending transaction created with id: ${insertedTx?.id}")
tx = tx.copy(id = insertedTx!!.id)
}.also {
twig("successfully created TX in DB")
}
} catch (t: Throwable) {
twig("Unknown error while attempting to create pending transaction: ${t.message} caused by: ${t.cause}")
}
emit(tx)
}
suspend fun manageMined(pendingTx: PendingTransaction, matchingMinedTx: Transaction) = withContext(IO) {
suspend fun manageMined(pendingTx: PendingTransactionEntity, matchingMinedTx: TransactionEntity) {
twig("a pending transaction has been mined!")
val tx = pendingTx.copy(minedHeight = matchingMinedTx.minedHeight!!)
dao.insert(tx)
safeUpdate(pendingTx.copy(minedHeight = matchingMinedTx.minedHeight!!))
}
/**
* Remove a transaction and pretend it never existed.
*/
suspend fun abortTransaction(existingTransaction: PendingTransaction) = withContext(IO) {
dao.delete(existingTransaction)
suspend fun abortTransaction(existingTransaction: PendingTransaction) {
pendingTransactionDao {
delete(existingTransaction as PendingTransactionEntity)
}
}
}
override fun encode(
spendingKey: String,
pendingTx: PendingTransaction
): Flow<PendingTransaction> = flow {
twig("managing the creation of a transaction")
//var tx = transaction.copy(expiryHeight = if (currentHeight == -1) -1 else currentHeight + EXPIRY_OFFSET)
var tx = pendingTx as PendingTransactionEntity
try {
twig("beginning to encode transaction with : $encoder")
val encodedTx = encoder.createTransaction(
spendingKey,
tx.value,
tx.toAddress,
tx.memo ?: "",
tx.accountIndex
)
twig("successfully encoded transaction for ${tx.memo}!!")
tx = tx.copy(raw = encodedTx.raw, rawTransactionId = encodedTx.txId)
} 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, errorCode = 2000) //TODO: find a place for these error codes
} finally {
tx = tx.copy(encodeAttempts = max(1, tx.encodeAttempts + 1))
}
safeUpdate(tx)
emit(tx)
}
override fun submit(pendingTx: PendingTransaction): Flow<PendingTransaction> = flow {
var tx1 = pendingTransactionDao { findById(pendingTx.id) }
if(tx1 == null) twig("unable to find transaction for id: ${pendingTx.id}")
var tx = tx1!!
try {
// do nothing when cancelled
if (!tx.isCancelled()) {
twig("submitting transaction to lightwalletd - memo: ${tx.memo} amount: ${tx.value}")
val response = service.submitTransaction(tx.raw!!)
val error = response.errorCode < 0
twig("${if (error) "FAILURE! " else "SUCCESS!"} submit transaction completed with response: ${response.errorCode}: ${response.errorMessage}")
tx = tx.copy(
errorMessage = if (error) response.errorMessage else null,
errorCode = response.errorCode,
submitAttempts = max(1, tx.submitAttempts + 1)
)
safeUpdate(tx)
} else {
twig("Warning: ignoring cancelled transaction with id ${tx.id}")
}
} catch (t: Throwable) {
// a non-server error has occurred
val message =
"Unknown error while submitting transaction: ${t.message} caused by: ${t.cause}"
twig(message)
tx = tx.copy(errorMessage = t.message, errorCode = 3000, submitAttempts = max(1, tx.submitAttempts + 1)) //TODO: find a place for these error codes
safeUpdate(tx)
}
emit(tx)
}
override suspend fun cancel(pendingTx: PendingTransaction): Boolean {
return pendingTransactionDao {
val tx = findById(pendingTx.id)
if (tx?.isSubmitted() == true) {
false
} else {
cancel(pendingTx.id)
true
}
}
}
override suspend fun getAll(): List<PendingTransaction> = pendingTransactionDao { getAll() }
/**
* Updating the pending transaction is often done at the end of a function but still should
* happen within a try/catch block, surrounded by logging. So this helps with that.
*/
private suspend fun safeUpdate(tx: PendingTransactionEntity): PendingTransaction {
return try {
twig("updating tx into DB: $tx")
pendingTransactionDao { update(tx) }
twig("successfully updated TX into DB")
tx
} catch (t: Throwable) {
twig("Unknown error while attempting to update pending transaction: ${t.message} caused by: ${t.cause}")
tx
}
}
private suspend fun <T> pendingTransactionDao(block: suspend PendingTransactionDao.() -> T): T {
return daoMutex.withLock {
_dao.block()
}
}
}

View File

@ -1,282 +1,295 @@
package cash.z.wallet.sdk.transaction
import cash.z.wallet.sdk.transaction.PersistentTransactionSender.ChangeType.*
import cash.z.wallet.sdk.transaction.TransactionUpdateRequest.RefreshSentTx
import cash.z.wallet.sdk.transaction.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.ext.twig
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 {
if (it.minedHeight != null) {
twig("matching mined 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) {
t.printStackTrace()
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()
}
//import cash.z.wallet.sdk.transaction.PersistentTransactionSender.ChangeType.*
//import cash.z.wallet.sdk.transaction.TransactionUpdateRequest.RefreshSentTx
//import cash.z.wallet.sdk.transaction.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.ext.twig
//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
// }
//
// override suspend fun initTransaction(
// zatoshiValue: Long,
// toAddress: String,
// memo: String,
// fromAccountIndex: Int
// ) = withContext(IO) {
// manager.initTransaction(
// zatoshiValue,
// toAddress,
// memo,
// fromAccountIndex
// )
// }
// /**
// * 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 {
// if (it.minedHeight != null) {
// twig("matching mined 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) {
// t.printStackTrace()
// twig("Error during updatePendingTransactions: $t caused by ${t.cause}")
// }
// }
//}
//
//private fun Int.asOrdinal(): String {
// return "$this" + if (this % 100 in 11..13) "th" else when(this % 10) {
// 1 -> "st"
// 2 -> "nd"
// 3 -> "rd"
// else -> "th"
// }
//}
//
//private fun LightWalletService.safeLatestBlockHeight(): Int {
// return try {
// getLatestBlockHeight()
// } catch (t: Throwable) {
// twig("Warning: LightWalletService failed to return the latest height and we are returning -1 instead.")
// -1
// }
//}
//
//sealed class TransactionUpdateRequest {
// object SubmitPendingTx : TransactionUpdateRequest()
// object RefreshSentTx : TransactionUpdateRequest()
//}

View File

@ -6,5 +6,11 @@ interface TransactionEncoder {
/**
* Creates a signed transaction
*/
suspend fun create(zatoshi: Long, toAddress: String, memo: String = ""): EncodedTransaction
suspend fun createTransaction(
spendingKey: String,
zatoshi: Long,
toAddress: String,
memo: String,
fromAccountIndex: Int
): EncodedTransaction
}

View File

@ -1,20 +1,30 @@
package cash.z.wallet.sdk.transaction
import cash.z.wallet.sdk.service.LightWalletService
import cash.z.wallet.sdk.entity.PendingTransaction
import kotlinx.coroutines.flow.Flow
/**
* 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.
* Manage outbound 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 outbound
* 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 OutboundTransactionManager {
fun initSpend(
zatoshi: Long,
toAddress: String,
memo: String,
fromAccountIndex: Int
): Flow<PendingTransaction>
fun encode(spendingKey: String, pendingTx: PendingTransaction): Flow<PendingTransaction>
fun submit(pendingTx: PendingTransaction): Flow<PendingTransaction>
/**
* Attempt to cancel a transaction.
*
* @return true when the transaction was able to be cancelled.
*/
suspend fun cancel(pendingTx: PendingTransaction): Boolean
suspend fun getAll(): List<PendingTransaction>
}
interface TransactionError {

View File

@ -1,11 +1,26 @@
package cash.z.wallet.sdk.transaction
import cash.z.wallet.sdk.entity.Transaction
import androidx.paging.PagedList
import cash.z.wallet.sdk.entity.*
import kotlinx.coroutines.flow.Flow
interface TransactionRepository {
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 findTransactionById(txId: Long): TransactionEntity?
suspend fun findTransactionByRawId(rawTransactionId: ByteArray): TransactionEntity?
/**
* Provides a way for other components to signal that the underlying data has been modified.
*/
fun invalidate()
//
// Transactions
//
val receivedTransactions: Flow<PagedList<ConfirmedTransaction>>
val sentTransactions: Flow<PagedList<ConfirmedTransaction>>
val allTransactions: Flow<PagedList<ConfirmedTransaction>>
}

View File

@ -4,16 +4,20 @@ import cash.z.wallet.sdk.entity.PendingTransaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.SendChannel
// TODO: delete this entire class and use managed transactions, instead
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 initTransaction(zatoshiValue: Long, toAddress: String, memo: String, fromAccountIndex: Int): ManagedTransaction
// 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 sendToAddress(encoder: TransactionEncoder, zatoshi: Long, toAddress: String, memo: String = "", fromAccountId: Int = 0): PendingTransaction
suspend fun cancel(existingTransaction: PendingTransaction): Unit?
var onSubmissionError: ((Throwable) -> Boolean)?
}
}
class SendResult

View File

@ -1,16 +1,21 @@
package cash.z.wallet.sdk.transaction
import cash.z.wallet.sdk.Initializer
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 cash.z.wallet.sdk.exception.TransactionEncoderException
import cash.z.wallet.sdk.ext.*
import cash.z.wallet.sdk.jni.RustBackend
import cash.z.wallet.sdk.jni.RustBackendWelding
import com.squareup.okhttp.OkHttpClient
import com.squareup.okhttp.Request
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
import okio.Okio
import java.io.File
class WalletTransactionEncoder(
private val wallet: Wallet,
private val repository: TransactionRepository,
private val spendingKeyProvider: Wallet.SpendingKeyProvider
private val rustBackend: RustBackendWelding,
private val repository: TransactionRepository
) : TransactionEncoder {
/**
@ -18,12 +23,141 @@ class WalletTransactionEncoder(
* 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.createSpend(spendingKeyProvider.key, zatoshi, toAddress, memo)
override suspend fun createTransaction(
spendingKey: String,
zatoshi: Long,
toAddress: String,
memo: String,
fromAccountIndex: Int
): EncodedTransaction = withContext(IO) {
val transactionId = createSpend(spendingKey, zatoshi, toAddress, memo)
val transaction = repository.findTransactionById(transactionId)
?: throw TransactionNotFoundException(transactionId)
?: throw TransactionEncoderException.TransactionNotFoundException(transactionId)
EncodedTransaction(transaction.transactionId, transaction.raw
?: throw TransactionNotEncodedException(transactionId)
?: throw TransactionEncoderException.TransactionNotEncodedException(transactionId)
)
}
/**
* Does the proofs and processing required to create a transaction to spend funds and inserts
* the result in the database. On average, this call takes over 10 seconds.
*
* @param value the zatoshi value to send
* @param toAddress the destination address
* @param memo the memo, which is not augmented in any way
*
* @return the row id in the transactions table that contains the spend transaction
* or -1 if it failed
*/
suspend fun createSpend(
spendingKey: String,
value: Long,
toAddress: String,
memo: String = "",
fromAccountIndex: Int = 0
): Long = withContext(IO) {
twigTask("creating transaction to spend $value zatoshi to" +
" ${toAddress.masked()} with memo $memo") {
try {
ensureParams((rustBackend as RustBackend).paramDestinationDir)
twig("params exist! attempting to send...")
rustBackend.createToAddress(
fromAccountIndex,
spendingKey,
toAddress,
value,
memo
)
} catch (t: Throwable) {
twig("${t.message}")
throw t
}
}.also { result ->
twig("result of sendToAddress: $result")
}
}
/**
* Checks the given directory for the output and spending params and calls [fetchParams] if
* they're missing.
*
* @param destinationDir the directory where the params should be stored.
*/
private suspend fun ensureParams(destinationDir: String) {
var hadError = false
arrayOf(
ZcashSdk.SPEND_PARAM_FILE_NAME,
ZcashSdk.OUTPUT_PARAM_FILE_NAME
).forEach { paramFileName ->
if (!File(destinationDir, paramFileName).exists()) {
twig("ERROR: $paramFileName not found at location: $destinationDir")
hadError = true
}
}
if (hadError) {
try {
Bush.trunk.twigTask("attempting to download missing params") {
fetchParams(destinationDir)
}
} catch (e: Throwable) {
twig("failed to fetch params due to: $e")
throw TransactionEncoderException.MissingParamsException
}
}
}
/**
* Download and store the params into the given directory.
*
* @param destinationDir the directory where the params will be stored. It's assumed that we
* have write access to this directory. Typically, this should be the app's cache directory
* because it is not harmful if these files are cleared by the user since they are downloaded
* on-demand.
*/
suspend fun fetchParams(destinationDir: String) = withContext(IO) {
val client = createHttpClient()
var failureMessage = ""
arrayOf(
ZcashSdk.SPEND_PARAM_FILE_NAME,
ZcashSdk.OUTPUT_PARAM_FILE_NAME
).forEach { paramFileName ->
val url = "${ZcashSdk.CLOUD_PARAM_DIR_URL}/$paramFileName"
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
twig("fetch succeeded")
val file = File(destinationDir, paramFileName)
if(file.parentFile.exists()) {
twig("directory exists!")
} else {
twig("directory did not exist attempting to make it")
file.parentFile.mkdirs()
}
Okio.buffer(Okio.sink(file)).use {
twig("writing to $file")
it.writeAll(response.body().source())
}
twig("fetch succeeded, done writing $paramFileName")
} else {
failureMessage += "Error while fetching $paramFileName : $response\n"
twig(failureMessage)
}
}
if (failureMessage.isNotEmpty()) throw TransactionEncoderException.FetchParamsException(failureMessage)
}
//
// Helpers
//
/**
* Http client is only used for downloading sapling spend and output params data, which are
* necessary for the wallet to scan blocks.
*/
private fun createHttpClient(): OkHttpClient {
//TODO: add logging and timeouts
return OkHttpClient()
}
}

View File

@ -124,7 +124,7 @@ internal class CompactBlockProcessorTest {
// then we should rewind the default (10) blocks
val expectedBlock = errorBlock - processor.config.rewindDistance
processBlocks(100L)
verify(processor.downloader, atLeastOnce()).rewindTo(expectedBlock)
verify(processor.downloader, atLeastOnce()).rewindToHeight(expectedBlock)
verify(rustBackend, atLeastOnce()).rewindToHeight("", expectedBlock)
assertNotNull(processor)
}