Documented everything public-facing.

This should bring documentation coverage to 100%
This commit is contained in:
Kevin Gorham 2020-02-27 12:28:10 -05:00
parent de0d85c20d
commit a43070cd6a
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
22 changed files with 592 additions and 463 deletions

View File

@ -4,7 +4,8 @@ import android.content.Context
import android.content.SharedPreferences
import cash.z.wallet.sdk.exception.BirthdayException
import cash.z.wallet.sdk.exception.InitializerException
import cash.z.wallet.sdk.ext.*
import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.jni.RustBackend
import com.google.gson.Gson
import com.google.gson.stream.JsonReader
@ -628,6 +629,52 @@ class Initializer(
)
}
}
/*
* Helper functions for using SharedPreferences
*/
/**
* Convenient constructor function for SharedPreferences used by this class.
*/
@Suppress("FunctionName")
private fun SharedPrefs(context: Context, name: String = "prefs"): SharedPreferences {
val fileName = "${BuildConfig.FLAVOR}.${BuildConfig.BUILD_TYPE}.$name".toLowerCase()
return context.getSharedPreferences(fileName, Context.MODE_PRIVATE)!!
}
private inline fun SharedPreferences.edit(block: (SharedPreferences.Editor) -> Unit) {
edit().run {
block(this)
apply()
}
}
private 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")
}
}
private inline operator fun <reified T : Any> SharedPreferences.get(
key: String,
defaultValue: T? = null
): T? {
return when (T::class) {
String::class -> getString(key, defaultValue as? String) as T?
Int::class -> getInt(key, defaultValue as? Int ?: -1) as T?
Boolean::class -> getBoolean(key, defaultValue as? Boolean ?: false) as T?
Float::class -> getFloat(key, defaultValue as? Float ?: -1f) as T?
Long::class -> getLong(key, defaultValue as? Long ?: -1) as T?
else -> throw UnsupportedOperationException("Not yet implemented")
}
}
}
}
}

View File

@ -109,11 +109,11 @@ interface Synchronizer {
/**
* Sends zatoshi.
*
* @param spendingKey the key that allows spends to occur.
* @param spendingKey the key associated with the notes that will be spent.
* @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.
* @param fromAccountIndex the optional account id to use. By default, the first account is used.
*
* @return a flow of PendingTransaction objects representing changes to the state of the
* transaction. Any time the state changes a new instance will be emitted by this flow. This is

View File

@ -8,6 +8,12 @@ import cash.z.wallet.sdk.entity.CompactBlockEntity
// Database
//
/**
* The "Cache DB", serving as a cache of compact blocks, waiting to be processed. This will contain
* the entire blockchain, from the birthdate of the wallet, forward. The [CompactBlockProcessor]
* will copy blocks from this database, as they are scanned. In the future, those blocks can be
* deleted because they are no longer needed. Currently, this efficiency has not been implemented.
*/
@Database(
entities = [CompactBlockEntity::class],
version = 1,
@ -22,6 +28,9 @@ abstract class CompactBlockDb : RoomDatabase() {
// Data Access Objects
//
/**
* Data access object for compact blocks in the "Cache DB."
*/
@Dao
interface CompactBlockDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@ -11,6 +11,13 @@ import cash.z.wallet.sdk.entity.*
// Database
//
/**
* The "Data DB," where all data derived from the compact blocks is stored. Most importantly, this
* database contains transaction information and can be queried for the current balance. The
* "blocks" table contains a copy of everything that has been scanned. In the future, that table can
* be truncated up to the last scanned block, for storage efficiency. Wallets should only read from,
* but never write to, this database.
*/
@Database(
entities = [
TransactionEntity::class,
@ -34,6 +41,9 @@ abstract class DerivedDataDb : RoomDatabase() {
// Data Access Objects
//
/**
* The data access object for blocks, used for determining the last scanned height.
*/
@Dao
interface BlockDao {
@Query("SELECT COUNT(height) FROM blocks")
@ -43,18 +53,28 @@ interface BlockDao {
fun lastScannedHeight(): Int
}
/**
* The data access object for notes, used for determining whether transactions exist.
*/
@Dao
interface ReceivedDao {
@Query("SELECT COUNT(tx) FROM received_notes")
fun count(): Int
}
/**
* The data access object for sent notes, used for determining whether outbound transactions exist.
*/
@Dao
interface SentDao {
@Query("SELECT COUNT(tx) FROM sent_notes")
fun count(): Int
}
/**
* The data access object for transactions, used for querying all transaction information, including
* whether transactions are mined.
*/
@Dao
interface TransactionDao {
@Query("SELECT COUNT(id_tx) FROM transactions")
@ -79,12 +99,6 @@ interface TransactionDao {
""")
fun findMinedHeight(rawTransactionId: ByteArray): Int?
// @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.
*/
@ -137,6 +151,9 @@ interface TransactionDao {
""")
fun getReceivedTransactions(limit: Int = Int.MAX_VALUE): DataSource.Factory<Int, ConfirmedTransaction>
/**
* Query all transactions, joining outbound and inbound transactions into the same table.
*/
@Query("""
SELECT transactions.id_tx AS id,
transactions.block AS minedHeight,

View File

@ -9,6 +9,12 @@ import kotlinx.coroutines.flow.Flow
// Database
//
/**
* Database for pending transaction information. Unlike with the "Data DB," the wallet is free to
* write to this database. In a way, this almost serves as a local mempool for all transactions
* initiated by this wallet. Currently, the data necessary to support expired transactions is there
* but it is not being leveraged.
*/
@Database(
entities = [
PendingTransactionEntity::class
@ -25,6 +31,9 @@ abstract class PendingTransactionDb : RoomDatabase() {
// Data Access Objects
//
/**
* Data access object providing crud for pending transactions.
*/
@Dao
interface PendingTransactionDao {
@Insert(onConflict = OnConflictStrategy.ABORT)

View File

@ -19,17 +19,27 @@ sealed class RustLayerException(message: String, cause: Throwable? = null) : Sdk
"blocks are not missing or have not been scanned out of order.", cause)
}
/**
* User-facing exceptions thrown by the transaction repository.
*/
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.")
}
/**
* High-level exceptions thrown by the synchronizer, which do not fall within the umbrealla of a
* child component.
*/
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."
)
}
/**
* Potentially user-facing exceptions that occur while processing compact blocks.
*/
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)`")
@ -48,13 +58,9 @@ sealed class CompactBlockProcessorException(message: String, cause: Throwable? =
" can be fixed by re-importing the wallet.")
}
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}")
}
/**
* Exceptions related to the wallet's birthday.
*/
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" +
@ -76,6 +82,9 @@ sealed class BirthdayException(message: String, cause: Throwable? = null) : SdkE
)
}
/**
* Exceptions thrown by the initializer.
*/
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, dbPath: String) : InitializerException("Failed to initialize the blocks table" +
@ -86,6 +95,9 @@ sealed class InitializerException(message: String, cause: Throwable? = null) :
" we can store data.")
}
/**
* Exceptions thrown while interacting with lightwalletd.
*/
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" +
@ -93,6 +105,9 @@ sealed class LightwalletException(message: String, cause: Throwable? = null) : S
" because this choice should be explicit.")
}
/**
* Potentially user-facing exceptions thrown while encoding transactions.
*/
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(

View File

@ -9,6 +9,14 @@ import java.math.RoundingMode
import java.text.NumberFormat
import java.util.*
/*
* Convenience functions for converting currency values for display in user interfaces. The
* calculations done here are not intended for financial purposes, because all such transactions
* are done using Zatoshis in the Rust layer. Instead, these functions are focused on displaying
* accurately rounded values to the user.
*/
//TODO: provide a dynamic way to configure this globally for the SDK
// For now, just make these vars so at least they could be modified in one place
object Conversions {
@ -29,11 +37,15 @@ object Conversions {
/**
* Format a Zatoshi value into Zec with the given number of digits, represented as a string.
* Start with Zatoshi -> End with Zec.
* Format a Zatoshi value into ZEC with the given number of digits, represented as a string.
* Start with Zatoshi -> End with ZEC.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better than Usd.
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is
* better than USD.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return this Zatoshi value represented as ZEC, in a string with at least [minDecimals] and at
* most [maxDecimals]
*/
inline fun Long?.convertZatoshiToZecString(
maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits,
@ -43,11 +55,15 @@ inline fun Long?.convertZatoshiToZecString(
}
/**
* Format a Zec value into Zec with the given number of digits, represented as a string.
* Start with ZeC -> End with Zec.
* Format a ZEC value into ZEC with the given number of digits, represented as a string.
* Start with ZEC -> End with ZEC.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better when right.
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is
* better when right.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return this Double ZEC value represented as a string with at least [minDecimals] and at most
* [maxDecimals].
*/
inline fun Double?.toZecString(
maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits,
@ -57,11 +73,15 @@ inline fun Double?.toZecString(
}
/**
* Format a Zatoshi value into Zec with the given number of decimal places, represented as a string.
* Start with ZeC -> End with Zec.
* Format a Zatoshi value into ZEC with the given number of decimal places, represented as a string.
* Start with ZeC -> End with ZEC.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better than bread.
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is
* better than bread.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return this BigDecimal ZEC value represented as a string with at least [minDecimals] and at most
* [maxDecimals].
*/
inline fun BigDecimal?.toZecString(
maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits,
@ -71,10 +91,15 @@ inline fun BigDecimal?.toZecString(
}
/**
* Format a Usd value into Usd with the given number of digits, represented as a string.
* Format a USD value into USD with the given number of digits, represented as a string.
* Start with USD -> end with USD.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better than pennies
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because
* ZEC is glorious.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return this Double ZEC value represented as a string with at least [minDecimals] and at most
* [maxDecimals], which is 2 by default. Zero is always represented without any decimals.
*/
inline fun Double?.toUsdString(
maxDecimals: Int = USD_FORMATTER.maximumFractionDigits,
@ -88,9 +113,15 @@ inline fun Double?.toUsdString(
}
/**
* Format a Zatoshi value into Usd with the given number of decimal places, represented as a string.
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is glorious.
* Format a USD value into USD with the given number of decimal places, represented as a string.
* Start with USD -> end with USD.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is
* glorious.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return this BigDecimal USD value represented as a string with at least [minDecimals] and at most
* [maxDecimals], which is 2 by default.
*/
inline fun BigDecimal?.toUsdString(
maxDecimals: Int = USD_FORMATTER.maximumFractionDigits,
@ -100,9 +131,15 @@ inline fun BigDecimal?.toUsdString(
}
/**
* Create a number formatter for use with converting currency to strings. This probably isn't needed externally since
* the other formatting functions leverage this, instead. Leverages the default rounding mode for zec found in
* ZEC_FORMATTER.
* Create a number formatter for use with converting currency to strings. This probably isn't needed
* externally since the other formatting functions leverage this, instead. Leverages the default
* rounding mode for zec found in ZEC_FORMATTER.
*
* @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is
* glorious.
* @param minDecimals the minimum number of digits to allow to the right of the decimal.
*
* @return a currency formatter, appropriate for the default locale.
*/
inline fun currencyFormatter(maxDecimals: Int, minDecimals: Int): NumberFormat {
return NumberFormat.getInstance(Locale.getDefault()).apply {
@ -114,59 +151,108 @@ inline fun currencyFormatter(maxDecimals: Int, minDecimals: Int): NumberFormat {
}
/**
* Convert a Zatoshi value into Zec, right-padded to the given number of fraction digits, represented as a BigDecimal in
* order to preserve rounding that minimizes cumulative error when applied repeatedly over a sequence of calculations.
* Start with Zatoshi -> End with Zec.
* Convert a Zatoshi value into ZEC, right-padded to the given number of fraction digits,
* represented as a BigDecimal in order to preserve rounding that minimizes cumulative error when
* applied repeatedly over a sequence of calculations.
* Start with Zatoshi -> End with ZEC.
*
* @param scale the number of digits to the right of the decimal place. Right-padding will be added, if necessary.
* @param scale the number of digits to the right of the decimal place. Right-padding will be added,
* if necessary.
*
* @return this Long Zatoshi value represented as ZEC using a BigDecimal with the given scale,
* rounded accurately out to 128 digits.
*/
inline fun Long?.convertZatoshiToZec(scale: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal {
return BigDecimal(this ?: 0L, MathContext.DECIMAL128).divide(Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128).setScale(scale, ZEC_FORMATTER.roundingMode)
return BigDecimal(this ?: 0L, MathContext.DECIMAL128).divide(
Conversions.ONE_ZEC_IN_ZATOSHI,
MathContext.DECIMAL128
).setScale(scale, ZEC_FORMATTER.roundingMode)
}
/**
* Convert a Zec value into Zatoshi.
* Convert a ZEC value into Zatoshi.
* Start with ZEC -> End with Zatoshi.
*
* @return this ZEC value represented as Zatoshi, rounded accurately out to 128 digits, in order to
* minimize cumulative errors when applied repeatedly over a sequence of calculations.
*/
inline fun BigDecimal?.convertZecToZatoshi(): Long {
if (this == null) return 0L
if (this < BigDecimal.ZERO) throw IllegalArgumentException("Invalid ZEC value: $this. ZEC is represented by notes and cannot be negative")
if (this < BigDecimal.ZERO) {
throw IllegalArgumentException("Invalid ZEC value: $this. ZEC is represented by notes and" +
" cannot be negative")
}
return this.multiply(Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128).toLong()
}
/**
* Format a Double Zec value as a BigDecimal Zec value, right-padded to the given number of fraction digits.
* Start with Zec -> End with Zec.
* Format a Double ZEC value as a BigDecimal ZEC value, right-padded to the given number of fraction
* digits.
* Start with ZEC -> End with ZEC.
*
* @param decimals the scale to use for the resulting BigDecimal.
*
* @return this Double ZEC value converted into a BigDecimal, with the proper rounding mode for use
* with other formatting functions.
*/
inline fun Double?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal {
return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(decimals, ZEC_FORMATTER.roundingMode)
return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(
decimals,
ZEC_FORMATTER.roundingMode
)
}
/**
* Format a Double Zec value as a Long Zatoshi value, by first converting to Zec with the given
* Format a Double ZEC value as a Long Zatoshi value, by first converting to ZEC with the given
* precision.
* Start with Zec -> End with Zatoshi.
* Start with ZEC -> End with Zatoshi.
*
* @param decimals the scale to use for the intermediate BigDecimal.
*
* @return this Double ZEC value converted into Zatoshi, with proper rounding and precision by
* leveraging an intermediate BigDecimal object.
*/
inline fun Double?.convertZecToZatoshi(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): Long {
return this.toZec(decimals).convertZecToZatoshi()
}
/**
* Format a BigDecimal Zec value as a BigDecimal Zec value, right-padded to the given number of fraction digits.
* Start with Zec -> End with Zec.
* Format a BigDecimal ZEC value as a BigDecimal ZEC value, right-padded to the given number of
* fraction digits.
* Start with ZEC -> End with ZEC.
*
* @param decimals the scale to use for the resulting BigDecimal.
*
* @return this BigDecimal ZEC adjusted to the default scale and rounding mode.
*/
inline fun BigDecimal?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal {
return (this ?: BigDecimal.ZERO).setScale(decimals, ZEC_FORMATTER.roundingMode)
}
/**
* Format a Double Usd value as a BigDecimal Usd value, right-padded to the given number of fraction digits.
* Format a Double USD value as a BigDecimal USD value, right-padded to the given number of fraction
* digits.
* Start with USD -> End with USD.
*
* @param decimals the scale to use for the resulting BigDecimal.
*
* @return this Double USD value converted into a BigDecimal, with proper rounding and precision.
*/
inline fun Double?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits): BigDecimal {
return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(decimals, USD_FORMATTER.roundingMode)
return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(
decimals,
USD_FORMATTER.roundingMode
)
}
/**
* Format a BigDecimal Usd value as a BigDecimal Usd value, right-padded to the given number of fraction digits.
* Format a BigDecimal USD value as a BigDecimal USD value, right-padded to the given number of
* fraction digits.
* Start with USD -> End with USD.
*
* @param decimals the scale to use for the resulting BigDecimal.
*
* @return this BigDecimal USD value converted into USD, with proper rounding and precision.
*/
inline fun BigDecimal?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits): BigDecimal {
return (this ?: BigDecimal.ZERO).setScale(decimals, USD_FORMATTER.roundingMode)
@ -174,30 +260,49 @@ inline fun BigDecimal?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits
/**
* Convert this ZEC value to USD, using the given price per ZEC.
* Start with ZEC -> End with USD.
*
* @param zecPrice the current price of ZEC represented as USD per ZEC
*
* @return this BigDecimal USD value converted into USD, with proper rounding and precision.
*/
inline fun BigDecimal?.convertZecToUsd(zecPrice: BigDecimal): BigDecimal {
if(this == null) return BigDecimal.ZERO
if(this < BigDecimal.ZERO) throw IllegalArgumentException("Invalid ZEC value: ${zecPrice.toDouble()}. ZEC is represented by notes and cannot be negative")
if(this < BigDecimal.ZERO) {
throw IllegalArgumentException("Invalid ZEC value: ${zecPrice.toDouble()}. ZEC is" +
" represented by notes and cannot be negative")
}
return this.multiply(zecPrice, MathContext.DECIMAL128)
}
/**
* Convert this USD value to ZEC, using the given price per ZEC.
* Start with USD -> End with ZEC.
*
* @param zecPrice the current price of ZEC represented as USD per ZEC
* @param zecPrice the current price of ZEC represented as USD per ZEC.
*
* @return this BigDecimal USD value converted into ZEC, with proper rounding and precision.
*/
inline fun BigDecimal?.convertUsdToZec(zecPrice: BigDecimal): BigDecimal {
if(this == null) return BigDecimal.ZERO
if(this < BigDecimal.ZERO) throw IllegalArgumentException("Invalid USD value: ${zecPrice.toDouble()}. Converting this would result in negative ZEC and ZEC is represented by notes and cannot be negative")
if(this < BigDecimal.ZERO) {
throw IllegalArgumentException("Invalid USD value: ${zecPrice.toDouble()}. Converting" +
" this would result in negative ZEC and ZEC is represented by notes and cannot be" +
" negative")
}
return this.divide(zecPrice, MathContext.DECIMAL128)
}
/**
* Convert this value from one currency to the other, based on given price and whether this value is USD.
* Convert this value from one currency to the other, based on given price and whether this value is
* USD.
* If starting with USD -> End with ZEC.
* If starting with ZEC -> End with USD.
*
* @param isUsd whether this value represents USD or not (ZEC)
* @param isUSD whether this value represents USD or not (ZEC)
*
* @return this BigDecimal value converted from one currency into the other, based on the given
* price.
*/
inline fun BigDecimal.convertCurrency(zecPrice: BigDecimal, isUsd: Boolean): BigDecimal {
return if (isUsd) {
@ -210,7 +315,7 @@ inline fun BigDecimal.convertCurrency(zecPrice: BigDecimal, isUsd: Boolean): Big
/**
* Parse this string into a BigDecimal, ignoring all non numeric characters.
*
* @return null when parsing fails
* @return this string as a BigDecimal or null when parsing fails.
*/
inline fun String?.safelyConvertToBigDecimal(): BigDecimal? {
if (this.isNullOrEmpty()) return BigDecimal.ZERO
@ -223,8 +328,34 @@ inline fun String?.safelyConvertToBigDecimal(): BigDecimal? {
}
}
inline fun String.toAbbreviatedAddress(startLength: Int = 8, endLength: Int = 8) = if (length > startLength + endLength) "${take(startLength)}${takeLast(endLength)}" else this
/**
* Abbreviates this string which is assumed to be an address.
*
* @param startLength the number of characters to show before the elipsis.
* @param endLength the number of characters to show after the elipsis.
*
* @return the abbreviated string unless the string is too short, in which case the original string
* is returned.
*/
inline fun String.toAbbreviatedAddress(startLength: Int = 8, endLength: Int = 8) =
if (length > startLength + endLength) "${take(startLength)}${takeLast(endLength)}" else this
internal inline fun String.masked(): String = if (startsWith("ztest") || startsWith("zs")) "****${takeLast(4)}" else "***masked***"
/**
* Masks the current string for use in logs. If this string appears to be an address, the last
* [addressCharsToShow] characters will be visible.
*
* @param addressCharsToShow the number of chars to show at the end, if this value appears to be an
* address.
*
* @return the masked version of this string, typically for use in logs.
*/
internal inline fun String.masked(addressCharsToShow: Int = 4): String =
if (startsWith("ztest") || startsWith("zs")) "****${takeLast(addressCharsToShow)}"
else "***masked***"
/**
* Convenience function that returns true when this string starts with 'z'.
*
* @return true when this function starts with 'z' rather than 't'.
*/
inline fun String?.isShielded() = this != null && startsWith('z')

View File

@ -38,6 +38,10 @@ fun <T> Flow<T>.collectWith(scope: CoroutineScope, block: (T) -> Unit) {
}
}
/**
* Utility for performing the given action on the first emission of a flow and running that action
* in the given scope.
*/
fun <T, S> Flow<T>.onFirstWith(scope: CoroutineScope, block: suspend (T) -> S) {
scope.launch {
onEach {
@ -46,6 +50,9 @@ fun <T, S> Flow<T>.onFirstWith(scope: CoroutineScope, block: suspend (T) -> S) {
}
}
/**
* Utility for performing the given action on the first emission of a flow.
*/
suspend fun <T, S> Flow<T>.onFirst(block: suspend (T) -> S) {
onEach {
block(it)

View File

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

View File

@ -10,7 +10,15 @@ internal typealias Leaf = String
* A tiny log.
*/
interface Twig {
/**
* Log the message. Simple.
*/
fun twig(logMessage: String = "")
/**
* Bundles twigs together
*/
operator fun plus(twig: Twig): Twig {
// if the other twig is a composite twig, let it handle the addition
return if(twig is CompositeTwig) twig.plus(this) else CompositeTwig(mutableListOf(this, twig))
@ -74,6 +82,10 @@ inline fun <R> twigTask(logMessage: String, block: () -> R): R = Bush.trunk.twig
* A tiny log that does nothing. No one hears this twig fall in the woods.
*/
class SilentTwig : Twig {
/**
* Shh.
*/
override fun twig(logMessage: String) {
// shh
}
@ -89,6 +101,10 @@ open class TroubleshootingTwig(
val formatter: (String) -> String = spiffy(5),
val printer: (String) -> Any = System.err::println
) : Twig {
/**
* Actually print and format the log message, unlike the SilentTwig, which does nothing.
*/
override fun twig(logMessage: String) {
printer(formatter(logMessage))
}

View File

@ -58,6 +58,15 @@ inline fun retrySimple(retries: Int = 2, sleepTime: Long = 20L, block: (Int) ->
}
}
/**
* Execute the given block and if it fails, retry with an exponential backoff.
*
* @param onErrorListener a callback that gets the first shot at processing any error and can veto
* the retry behavior by returning false.
* @param initialDelayMillis the initial delay before retrying.
* @param maxDelayMillis the maximum delay between retrys.
* @param block the logic to run once and then run again if it fails.
*/
suspend inline fun retryWithBackoff(noinline onErrorListener: ((Throwable) -> Boolean)? = null, initialDelayMillis: Long = 1000L, maxDelayMillis: Long = MAX_BACKOFF_INTERVAL, block: () -> Unit) {
var sequence = 0 // count up to the max and then reset to half. So that we don't repeat the max but we also don't repeat too much.
while (true) {
@ -83,6 +92,11 @@ suspend inline fun retryWithBackoff(noinline onErrorListener: ((Throwable) -> Bo
}
}
/**
* Return true if the given database already exists.
*
* @return true when the given database exists in the given context.
*/
internal fun dbExists(appContext: Context, dbFileName: String): Boolean {
return File(appContext.getDatabasePath(dbFileName).absolutePath).exists()
}

View File

@ -3,6 +3,8 @@ package cash.z.wallet.sdk.jni
/**
* Contract defining the exposed capabilities of the Rust backend.
* This is what welds the SDK to the Rust layer.
* It is not documented because it is not intended to be used, directly.
* Instead, use the synchronizer or one of its subcomponents.
*/
interface RustBackendWelding {

View File

@ -17,10 +17,10 @@ import java.util.concurrent.TimeUnit
/**
* Implementation of LightwalletService using gRPC for requests to lightwalletd.
*
* @param channel the channel to use for communicating with the lightwalletd server.
* @param singleRequestTimeoutSec the timeout to use for non-streaming requests. When a new stub is
* @property channel the channel to use for communicating with the lightwalletd server.
* @property singleRequestTimeoutSec the timeout to use for non-streaming requests. When a new stub is
* created, it will use a deadline that is after the given duration from now.
* @param streamingRequestTimeoutSec the timeout to use for streaming requests. When a new stub is
* @property streamingRequestTimeoutSec the timeout to use for streaming requests. When a new stub is
* created for streaming requests, it will use a deadline that is after the given duration from now.
*/
class LightWalletGrpcService private constructor(
@ -29,6 +29,16 @@ class LightWalletGrpcService private constructor(
private val streamingRequestTimeoutSec: Long = 90L
) : LightWalletService {
/**
* Construct an instance that corresponds to the given host and port.
*
* @param appContext the application context used to check whether TLS is required by this build
* flavor.
* @param host the host of the server to use.
* @param port the port of the server to use.
* @param usePlaintext whether to use TLS or plaintext for requests. Plaintext is dangerous so
* it requires jumping through a few more hoops.
*/
constructor(
appContext: Context,
host: String,
@ -38,12 +48,6 @@ class LightWalletGrpcService private constructor(
/* LightWalletService implementation */
/**
* Blocking call to download all blocks in the given range.
*
* @param heightRange the inclusive range of block heights to download.
* @return a list of compact blocks for the given range
*/
override fun getBlockRange(heightRange: IntRange): List<CompactFormats.CompactBlock> {
channel.resetConnectBackoff()
return channel.createStub(streamingRequestTimeoutSec).getBlockRange(heightRange.toBlockRange()).toList()
@ -89,6 +93,11 @@ class LightWalletGrpcService private constructor(
}
companion object {
/**
* Convenience function for creating the default channel to be used for all connections. It
* is important that this channel can handle transitioning from WiFi to Cellular connections
* and is properly setup to support TLS, when required.
*/
fun createDefaultChannel(
appContext: Context,
host: String,

View File

@ -13,16 +13,23 @@ interface LightWalletService {
*
* @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.
*
* @return a list of compact blocks for the given range
*
*/
fun getBlockRange(heightRange: IntRange): List<CompactFormats.CompactBlock>
/**
* Return the latest block height known to the service.
*
* @return the latest block height known to the service.
*/
fun getLatestBlockHeight(): Int
/**
* Submit a raw transaction.
*
* @return the response from the server.
*/
fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse

View File

@ -79,6 +79,9 @@ open class PagedTransactionRepository(
transactions.findMinedHeight(rawTransactionId)
}
/**
* Close the underlying database.
*/
fun close() {
derivedDataDb.close()
}
@ -109,7 +112,7 @@ open class PagedTransactionRepository(
// }
// }
val MIGRATION_4_3 = object : Migration(4, 3) {
private val MIGRATION_4_3 = object : Migration(4, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("PRAGMA foreign_keys = OFF;")
database.execSQL(

View File

@ -5,7 +5,10 @@ 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.*
import cash.z.wallet.sdk.entity.PendingTransaction
import cash.z.wallet.sdk.entity.PendingTransactionEntity
import cash.z.wallet.sdk.entity.isCancelled
import cash.z.wallet.sdk.entity.isSubmitted
import cash.z.wallet.sdk.ext.twig
import cash.z.wallet.sdk.service.LightWalletService
import kotlinx.coroutines.Dispatchers
@ -13,13 +16,19 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.lang.IllegalStateException
import kotlin.math.max
/**
* Facilitates persistent attempts to ensure a transaction occurs.
* Facilitates persistent attempts to ensure that an outbound transaction is completed.
*
* @param db the database where the wallet can freely write information related to pending
* transactions. This database effectively serves as the mempool for transactions created by this
* wallet.
* @property encoder responsible for encoding a transaction by taking all the inputs and returning
* an [cash.z.wallet.sdk.entity.EncodedTransaction] object containing the raw bytes and transaction
* id.
* @property service the lightwallet service used to submit transactions.
*/
// TODO: consider having the manager register the fail listeners rather than having that responsibility spread elsewhere (synchronizer and the broom)
class PersistentTransactionManager(
db: PendingTransactionDb,
private val encoder: TransactionEncoder,
@ -52,10 +61,11 @@ class PersistentTransactionManager(
service
)
/**
* Initialize a [PendingTransaction] and then insert it in the database for monitoring and
* follow-up.
*/
//
// OutboundTransactionManager implementation
//
override suspend fun initSpend(
zatoshiValue: Long,
toAddress: String,
@ -79,7 +89,8 @@ class PersistentTransactionManager(
twig("successfully created TX in DB")
}
} catch (t: Throwable) {
twig("Unknown error while attempting to create pending transaction: ${t.message} caused by: ${t.cause}")
twig("Unknown error while attempting to create pending transaction: ${t.message}" +
" caused by: ${t.cause}")
}
tx
@ -92,21 +103,11 @@ class PersistentTransactionManager(
}
}
/**
* Remove a transaction and pretend it never existed.
*/
suspend fun abortTransaction(existingTransaction: PendingTransaction) {
pendingTransactionDao {
delete(existingTransaction as PendingTransactionEntity)
}
}
override suspend fun encode(
spendingKey: String,
pendingTx: PendingTransaction
): PendingTransaction = withContext(Dispatchers.IO) {
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")
@ -123,7 +124,7 @@ class PersistentTransactionManager(
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
tx = tx.copy(errorMessage = message, errorCode = ERROR_ENCODING)
} finally {
tx = tx.copy(encodeAttempts = max(1, tx.encodeAttempts + 1))
}
@ -134,15 +135,19 @@ class PersistentTransactionManager(
override suspend fun submit(pendingTx: PendingTransaction): PendingTransaction = withContext(Dispatchers.IO) {
// reload the tx to check for cancellation
var storedTx = pendingTransactionDao { findById(pendingTx.id) } ?: throw IllegalStateException("Error while submitting transaction. No pending transaction found that matches the one being submitted. Verify that the transaction still exists among the set of pending transactions.")
var storedTx = pendingTransactionDao { findById(pendingTx.id) }
?: throw IllegalStateException("Error while submitting transaction. No pending" +
" transaction found that matches the one being submitted. Verify that the" +
" transaction still exists among the set of pending transactions.")
var tx = storedTx
try {
// do nothing when cancelled
if (!tx.isCancelled()) {
twig("submitting transaction to lightwalletd - memo: ${tx.memo} amount: ${tx.value}")
twig("submitting transaction with 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}")
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,
@ -157,7 +162,11 @@ class PersistentTransactionManager(
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
tx = tx.copy(
errorMessage = t.message,
errorCode = ERROR_SUBMITTING,
submitAttempts = max(1, tx.submitAttempts + 1)
)
safeUpdate(tx)
}
@ -188,18 +197,33 @@ class PersistentTransactionManager(
override fun getAll() = _dao.getAll()
//
// Helper functions
//
/**
* Remove a transaction and pretend it never existed.
*/
suspend fun abortTransaction(existingTransaction: PendingTransaction) {
pendingTransactionDao {
delete(existingTransaction as PendingTransactionEntity)
}
}
/**
* 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")
twig("updating tx in DB: $tx")
pendingTransactionDao { update(tx) }
twig("successfully updated TX into DB")
twig("successfully updated TX in DB")
tx
} catch (t: Throwable) {
twig("Unknown error while attempting to update pending transaction: ${t.message} caused by: ${t.cause}")
twig("Unknown error while attempting to update pending transaction: ${t.message}" +
" caused by: ${t.cause}")
tx
}
}
@ -209,5 +233,12 @@ class PersistentTransactionManager(
_dao.block()
}
}
companion object {
/** Error code for an error while encoding a transaction */
const val ERROR_ENCODING = 2000
/** Error code for an error while submitting a transaction */
const val ERROR_SUBMITTING = 3000
}
}

View File

@ -1,295 +0,0 @@
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
// }
//
// override suspend fun initTransaction(
// zatoshiValue: Long,
// toAddress: String,
// memo: String,
// fromAccountIndex: Int
// ) = withContext(IO) {
// manager.initTransaction(
// zatoshiValue,
// toAddress,
// memo,
// fromAccountIndex
// )
// }
// /**
// * Generates newly persisted information about a transaction so that other processes can send.
// */
//// override suspend fun sendToAddress(
//// encoder: TransactionEncoder,
//// zatoshi: Long,
//// toAddress: String,
//// memo: String,
//// fromAccountId: Int
//// ): PendingTransaction = withContext(IO) {
//// val currentHeight = service.safeLatestBlockHeight()
//// (manager as PersistentTransactionManager).manageCreation(encoder, zatoshi, toAddress, memo, currentHeight).also {
//// requestUpdate(true)
//// }
//// }
//
//// override suspend fun prepareTransaction(
//// zatoshiValue: Long,
//// address: String,
//// memo: String
//// ): PendingTransaction? = withContext(IO) {
//// (manager as PersistentTransactionManager).initPlaceholder(zatoshiValue, address, memo).also {
//// // update UI to show what we've just created. No need to submit, it has no raw data yet!
//// requestUpdate(false)
//// }
//// }
//
//// override suspend fun sendPreparedTransaction(
//// encoder: TransactionEncoder,
//// tx: PendingTransaction
//// ): PendingTransaction = withContext(IO) {
//// val currentHeight = service.safeLatestBlockHeight()
//// (manager as PersistentTransactionManager).manageCreation(encoder, tx, currentHeight).also {
//// // submit what we've just created
//// requestUpdate(true)
//// }
//// }
//
// override suspend fun cleanupPreparedTransaction(tx: PendingTransaction) {
// if (tx.raw.isEmpty()) {
// (manager as PersistentTransactionManager).abortTransaction(tx)
// }
// }
//
// // TODO: get this from the channel instead
// var previousSentTxs: List<PendingTransaction>? = null
//
// private suspend fun notifyIfChanged(currentSentTxs: List<PendingTransaction>) = withContext(IO) {
// if (hasChanged(previousSentTxs, currentSentTxs) && listenerChannel?.isClosedForSend != true) {
// twig("START notifying listenerChannel of changed txs")
// listenerChannel?.send(currentSentTxs)
// twig("DONE notifying listenerChannel of changed txs")
// previousSentTxs = currentSentTxs
// } else {
// twig("notifyIfChanged: did nothing because ${if(listenerChannel?.isClosedForSend == true) "the channel is closed." else "nothing changed."}")
// }
// }
//
// override suspend fun cancel(existingTransaction: PendingTransaction) = withContext(IO) {
// (manager as PersistentTransactionManager).abortTransaction(existingTransaction). also {
// requestUpdate(false)
// }
// }
//
// private fun hasChanged(
// previousSents: List<PendingTransaction>?,
// currentSents: List<PendingTransaction>
// ): Boolean {
// // shortcuts first
// if (currentSents.isEmpty() && previousSents.isNullOrEmpty()) return false.also {
// val count = if (lastChangeDetected is BackoffEnabled) ((lastChangeDetected as? BackoffEnabled)?.count ?: 0) + 1 else 1
// lastChangeDetected = NothingPending(count)
// }
// if (previousSents == null) return true.also { lastChangeDetected = FirstChange }
// if (previousSents.size != currentSents.size) return true.also { lastChangeDetected = SizeChange(previousSentTxs?.size ?: -1, currentSents.size) }
// for (tx in currentSents) {
// // note: implicit .equals check inside `contains` will also detect modifications
// if (!previousSents.contains(tx)) return true.also { lastChangeDetected = Modified(tx) }
// }
// return false.also {
// val count = if (lastChangeDetected is BackoffEnabled) ((lastChangeDetected as? BackoffEnabled)?.count ?: 0) + 1 else 1
// lastChangeDetected = NoChange(count)
// }
// }
//
// sealed class ChangeType(val description: String) {
// object FirstChange : ChangeType("This is the first time we've seen a change!")
// data class NothingPending(override val count: Int) : ChangeType("Nothing happened yet!"), BackoffEnabled
// data class NoChange(override val count: Int) : ChangeType("No changes"), BackoffEnabled
// class SizeChange(val oldSize: Int, val newSize: Int) : ChangeType("The total number of pending transactions has changed")
// class Modified(val tx: PendingTransaction) : ChangeType("At least one transaction has been modified")
// }
// interface BackoffEnabled {
// val count: Int
// }
//
// /**
// * Check on all sent transactions and if they've changed, notify listeners. This method can be called proactively
// * when anything interesting has occurred with a transaction (via [requestUpdate]).
// */
// private suspend fun refreshSentTransactions(): List<PendingTransaction> = withContext(IO) {
// val allSentTransactions = (manager as PersistentTransactionManager).getAll() // TODO: make this crash and catch error gracefully
// notifyIfChanged(allSentTransactions)
// allSentTransactions
// }
//
// /**
// * Submit all pending transactions that have not expired.
// */
// private suspend fun updatePendingTransactions() = withContext(IO) {
// try {
// val allTransactions = refreshSentTransactions()
// var pendingCount = 0
// val currentHeight = service.safeLatestBlockHeight()
// allTransactions.filter { !it.isMined() }.forEach { tx ->
// if (tx.isPending(currentHeight)) {
// pendingCount++
// retryWithBackoff(onSubmissionError, 1000L, 60_000L) {
// manager.manageSubmission(service, tx)
// }
// } else {
// tx.rawTransactionId?.let {
// ledger.findTransactionByRawId(tx.rawTransactionId)
// }?.let {
// if (it.minedHeight != null) {
// twig("matching mined transaction found! $tx")
// (manager as PersistentTransactionManager).manageMined(tx, it)
// refreshSentTransactions()
// }
// }
// }
// }
// twig("given current height $currentHeight, we found $pendingCount pending txs to submit")
// } catch (t: Throwable) {
// t.printStackTrace()
// twig("Error during updatePendingTransactions: $t caused by ${t.cause}")
// }
// }
//}
//
//private fun Int.asOrdinal(): String {
// return "$this" + if (this % 100 in 11..13) "th" else when(this % 10) {
// 1 -> "st"
// 2 -> "nd"
// 3 -> "rd"
// else -> "th"
// }
//}
//
//private fun LightWalletService.safeLatestBlockHeight(): Int {
// return try {
// getLatestBlockHeight()
// } catch (t: Throwable) {
// twig("Warning: LightWalletService failed to return the latest height and we are returning -1 instead.")
// -1
// }
//}
//
//sealed class TransactionUpdateRequest {
// object SubmitPendingTx : TransactionUpdateRequest()
// object RefreshSentTx : TransactionUpdateRequest()
//}

View File

@ -4,7 +4,17 @@ import cash.z.wallet.sdk.entity.EncodedTransaction
interface TransactionEncoder {
/**
* Creates a signed transaction
* Creates a transaction, throwing an exception whenever things are missing. When the provided
* wallet implementation doesn't throw an exception, we wrap the issue into a descriptive
* exception ourselves (rather than using double-bangs for things).
*
* @param spendingKey the key associated with the notes that will be spent.
* @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 fromAccountIndex the optional account id to use. By default, the 1st account is used.
*
* @return the successfully encoded transaction or an exception
*/
suspend fun createTransaction(
spendingKey: String,
@ -14,6 +24,23 @@ interface TransactionEncoder {
fromAccountIndex: Int = 0
): EncodedTransaction
/**
* Utility function to help with validation. This is not called during [createTransaction]
* because this class asserts that all validation is done externally by the UI, for now.
*
* @param address the address to validate
*
* @return true when the given address is a valid z-addr
*/
suspend fun isValidShieldedAddress(address: String): Boolean
/**
* Utility function to help with validation. This is not called during [createTransaction]
* because this class asserts that all validation is done externally by the UI, for now.
*
* @param address the address to validate
*
* @return true when the given address is a valid t-addr
*/
suspend fun isValidTransparentAddress(address: String): Boolean
}

View File

@ -9,29 +9,110 @@ import kotlinx.coroutines.flow.Flow
* transactions through to completion.
*/
interface OutboundTransactionManager {
/**
* Initialize a spend with the main purpose of creating an idea to use for tracking it until
* completion.
*
* @param zatoshi the amount to spend.
* @param toAddress the address to which funds will be sent.
* @param memo the optionally blank memo associated with this transaction.
* @param fromAccountIndex the account from which to spend funds.
*
* @return the associated pending transaction whose ID can be used to monitor for changes.
*/
suspend fun initSpend(
zatoshi: Long,
toAddress: String,
memo: String,
fromAccountIndex: Int
): PendingTransaction
/**
* Encode the pending transaction using the given spending key. This is a local operation that
* produces a raw transaction to submit to lightwalletd.
*
* @param spendingKey the spendingKey to use for constructing the transaction.
* @param pendingTx the transaction information created by [initSpend] that will be used to
* construct a transaction.
*
* @return the resulting pending transaction whose ID can be used to monitor for changes.
*/
suspend fun encode(spendingKey: String, pendingTx: PendingTransaction): PendingTransaction
/**
* Submits the transaction represented by [pendingTx] to lightwalletd to broadcast to the
* network and, hopefully, include in the next block.
*
* @param pendingTx the transaction information containing the raw bytes that will be submitted
* to lightwalletd.
*
* @return the resulting pending transaction whose ID can be used to monitor for changes.
*/
suspend fun submit(pendingTx: PendingTransaction): PendingTransaction
/**
* Given a transaction and the height at which it was mined, update the transaction to indicate
* that it was mined.
*
* @param pendingTx the pending transaction that has been mineed.
* @param minedHeight the height at which the given transaction was mined, according to the data
* that has been processed from the blockchain.
*/
suspend fun applyMinedHeight(pendingTx: PendingTransaction, minedHeight: Int)
/**
* Generate a flow of information about the given id where a new pending transaction is emitted
* every time its state changes.
*
* @param id the id to monitor.
*
* @return a flow of pending transactions that are emitted anytime the transaction associated
* withh the given id changes.
*/
suspend fun monitorById(id: Long): Flow<PendingTransaction>
/**
* Return true when the given address is a valid t-addr.
*
* @param address the address to validate.
*
* @return true when the given address is a valid t-addr.
*/
suspend fun isValidShieldedAddress(address: String): Boolean
/**
* Return true when the given address is a valid z-addr.
*
* @param address the address to validate.
*
* @return true when the given address is a valid z-addr.
*/
suspend fun isValidTransparentAddress(address: String): Boolean
/**
* Attempt to cancel a transaction.
*
* @param pendingTx the transaction matching the ID of the transaction to cancel.
*
* @return true when the transaction was able to be cancelled.
*/
suspend fun cancel(pendingTx: PendingTransaction): Boolean
/**
* Get all pending transactions known to this wallet as a flow that is updated anytime the list
* changes.
*
* @return a flow of all pending transactions known to this wallet.
*/
fun getAll(): Flow<List<PendingTransaction>>
}
/**
* Interface for transaction errors.
*/
interface TransactionError {
/**
* The message associated with this error.
*/
val message: String
}

View File

@ -8,9 +8,38 @@ import kotlinx.coroutines.flow.Flow
* Repository of wallet transactions, providing an agnostic interface to the underlying information.
*/
interface TransactionRepository {
/**
* The last height scanned by this repository.
*
* @return the last height scanned by this repository.
*/
fun lastScannedHeight(): Int
/**
* Returns true when this repository has been initialized and seeded with the initial checkpoint.
*
* @return true when this repository has been initialized and seeded with the initial checkpoint.
*/
fun isInitialized(): Boolean
/**
* Find the encoded transaction associated with the given id.
*
* @param txId the id of the transaction to find.
*
* @return the transaction or null when it cannot be found.
*/
suspend fun findEncodedTransactionById(txId: Long): EncodedTransaction?
/**
* Find the mined height that matches the given raw tx_id in bytes. This is useful for matching
* a pending transaction with one that we've decrypted from the blockchain.
*
* @param rawTransactionId the id of the transaction to find.
*
* @return the mined height of the given transaction, if it is known to this wallet.
*/
suspend fun findMinedHeight(rawTransactionId: ByteArray): Int?
/**
@ -23,7 +52,10 @@ interface TransactionRepository {
// Transactions
//
/** A flow of all the inbound confirmed transactions */
val receivedTransactions: Flow<PagedList<ConfirmedTransaction>>
/** A flow of all the outbound confirmed transactions */
val sentTransactions: Flow<PagedList<ConfirmedTransaction>>
/** A flow of all the inbound and outbound confirmed transactions */
val allTransactions: Flow<PagedList<ConfirmedTransaction>>
}

View File

@ -1,23 +0,0 @@
package cash.z.wallet.sdk.transaction
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 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 cancel(existingTransaction: PendingTransaction): Unit?
var onSubmissionError: ((Throwable) -> Boolean)?
}
class SendResult

View File

@ -12,15 +12,32 @@ import kotlinx.coroutines.withContext
import okio.Okio
import java.io.File
/**
* Class responsible for encoding a transaction in a consistent way. This bridges the gap by
* behaving like a stateless API so that callers can request [createTransaction] and receive a
* result, even though there are intermediate database interactions.
*
* @property rustBackend the instance of RustBackendWelding to use for creating and validating.
* @property repository the repository that stores information about the transactions being created
* such as the raw bytes and raw txId.
*/
class WalletTransactionEncoder(
private val rustBackend: RustBackendWelding,
private val repository: TransactionRepository
) : TransactionEncoder {
/**
* Creates a transaction, throwing an exception whenever things are missing. When the provided wallet implementation
* doesn't throw an exception, we wrap the issue into a descriptive exception ourselves (rather than using
* double-bangs for things).
* Creates a transaction, throwing an exception whenever things are missing. When the provided
* wallet implementation doesn't throw an exception, we wrap the issue into a descriptive
* exception ourselves (rather than using double-bangs for things).
*
* @param spendingKey the key associated with the notes that will be spent.
* @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 fromAccountIndex the optional account id to use. By default, the 1st account is used.
*
* @return the successfully encoded transaction or an exception
*/
override suspend fun createTransaction(
spendingKey: String,
@ -37,6 +54,10 @@ class WalletTransactionEncoder(
/**
* Utility function to help with validation. This is not called during [createTransaction]
* because this class asserts that all validation is done externally by the UI, for now.
*
* @param address the address to validate
*
* @return true when the given address is a valid z-addr
*/
override suspend fun isValidShieldedAddress(address: String): Boolean = withContext(IO) {
rustBackend.isValidShieldedAddr(address)
@ -45,6 +66,10 @@ class WalletTransactionEncoder(
/**
* Utility function to help with validation. This is not called during [createTransaction]
* because this class asserts that all validation is done externally by the UI, for now.
*
* @param address the address to validate
*
* @return true when the given address is a valid t-addr
*/
override suspend fun isValidTransparentAddress(address: String): Boolean = withContext(IO) {
rustBackend.isValidTransparentAddr(address)
@ -54,21 +79,23 @@ class WalletTransactionEncoder(
* 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
* @param spendingKey the key associated with the notes that will be spent.
* @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 fromAccountIndex the optional account id to use. By default, the 1st account is used.
*
* @return the row id in the transactions table that contains the spend transaction
* or -1 if it failed
* @return the row id in the transactions table that contains the spend transaction or -1 if it
* failed.
*/
private suspend fun createSpend(
spendingKey: String,
value: Long,
zatoshi: Long,
toAddress: String,
memo: ByteArray? = byteArrayOf(),
fromAccountIndex: Int = 0
): Long = withContext(IO) {
twigTask("creating transaction to spend $value zatoshi to" +
twigTask("creating transaction to spend $zatoshi zatoshi to" +
" ${toAddress.masked()} with memo $memo") {
try {
ensureParams((rustBackend as RustBackend).pathParamsDir)
@ -77,7 +104,7 @@ class WalletTransactionEncoder(
fromAccountIndex,
spendingKey,
toAddress,
value,
zatoshi,
memo
)
} catch (t: Throwable) {
@ -166,6 +193,8 @@ class WalletTransactionEncoder(
/**
* Http client is only used for downloading sapling spend and output params data, which are
* necessary for the wallet to scan blocks.
*
* @return an http client suitable for downloading params data.
*/
private fun createHttpClient(): OkHttpClient {
//TODO: add logging and timeouts