Refactoring
This commit is contained in:
parent
bf48b82aa8
commit
9cb178d6fb
|
@ -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}"
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 = ""
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)?
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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"]
|
||||
)]
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
//}
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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/"
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
//}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>>
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue