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 android.content.SharedPreferences
import cash.z.wallet.sdk.exception.BirthdayException import cash.z.wallet.sdk.exception.BirthdayException
import cash.z.wallet.sdk.exception.InitializerException 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 cash.z.wallet.sdk.jni.RustBackend
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.stream.JsonReader 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. * 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 zatoshi the amount of zatoshi to send.
* @param toAddress the recipient's address. * @param toAddress the recipient's address.
* @param memo the optional memo to include as part of the transaction. * @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 * @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 * 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 // 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( @Database(
entities = [CompactBlockEntity::class], entities = [CompactBlockEntity::class],
version = 1, version = 1,
@ -22,6 +28,9 @@ abstract class CompactBlockDb : RoomDatabase() {
// Data Access Objects // Data Access Objects
// //
/**
* Data access object for compact blocks in the "Cache DB."
*/
@Dao @Dao
interface CompactBlockDao { interface CompactBlockDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@ -11,6 +11,13 @@ import cash.z.wallet.sdk.entity.*
// Database // 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( @Database(
entities = [ entities = [
TransactionEntity::class, TransactionEntity::class,
@ -34,6 +41,9 @@ abstract class DerivedDataDb : RoomDatabase() {
// Data Access Objects // Data Access Objects
// //
/**
* The data access object for blocks, used for determining the last scanned height.
*/
@Dao @Dao
interface BlockDao { interface BlockDao {
@Query("SELECT COUNT(height) FROM blocks") @Query("SELECT COUNT(height) FROM blocks")
@ -43,18 +53,28 @@ interface BlockDao {
fun lastScannedHeight(): Int fun lastScannedHeight(): Int
} }
/**
* The data access object for notes, used for determining whether transactions exist.
*/
@Dao @Dao
interface ReceivedDao { interface ReceivedDao {
@Query("SELECT COUNT(tx) FROM received_notes") @Query("SELECT COUNT(tx) FROM received_notes")
fun count(): Int fun count(): Int
} }
/**
* The data access object for sent notes, used for determining whether outbound transactions exist.
*/
@Dao @Dao
interface SentDao { interface SentDao {
@Query("SELECT COUNT(tx) FROM sent_notes") @Query("SELECT COUNT(tx) FROM sent_notes")
fun count(): Int fun count(): Int
} }
/**
* The data access object for transactions, used for querying all transaction information, including
* whether transactions are mined.
*/
@Dao @Dao
interface TransactionDao { interface TransactionDao {
@Query("SELECT COUNT(id_tx) FROM transactions") @Query("SELECT COUNT(id_tx) FROM transactions")
@ -79,12 +99,6 @@ interface TransactionDao {
""") """)
fun findMinedHeight(rawTransactionId: ByteArray): Int? 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. * 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> fun getReceivedTransactions(limit: Int = Int.MAX_VALUE): DataSource.Factory<Int, ConfirmedTransaction>
/**
* Query all transactions, joining outbound and inbound transactions into the same table.
*/
@Query(""" @Query("""
SELECT transactions.id_tx AS id, SELECT transactions.id_tx AS id,
transactions.block AS minedHeight, transactions.block AS minedHeight,

View File

@ -9,6 +9,12 @@ import kotlinx.coroutines.flow.Flow
// Database // 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( @Database(
entities = [ entities = [
PendingTransactionEntity::class PendingTransactionEntity::class
@ -25,6 +31,9 @@ abstract class PendingTransactionDb : RoomDatabase() {
// Data Access Objects // Data Access Objects
// //
/**
* Data access object providing crud for pending transactions.
*/
@Dao @Dao
interface PendingTransactionDao { interface PendingTransactionDao {
@Insert(onConflict = OnConflictStrategy.ABORT) @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) "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) { 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 " + 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.") "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) { 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" + 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." "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) { 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 " + 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)`") "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.") " 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.") * Exceptions related to the wallet's birthday.
class FalseStart(cause: Throwable?): CompactBlockStreamException("Failed to start compact block stream due to " + */
"$cause caused by ${cause?.cause}")
}
sealed class BirthdayException(message: String, cause: Throwable? = null) : SdkException(message, cause) { sealed class BirthdayException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object UninitializedBirthdayException : BirthdayException("Error the birthday cannot be" + object UninitializedBirthdayException : BirthdayException("Error the birthday cannot be" +
" accessed before it is initialized. Verify that the new, import or open functions" + " 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){ 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 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" + 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.") " we can store data.")
} }
/**
* Exceptions thrown while interacting with lightwalletd.
*/
sealed class LightwalletException(message: String, cause: Throwable? = null) : SdkException(message, cause) { sealed class LightwalletException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
object InsecureConnection : LightwalletException("Error: attempted to connect to lightwalletd" + object InsecureConnection : LightwalletException("Error: attempted to connect to lightwalletd" +
" with an insecure connection! Plaintext connections are only allowed when the" + " 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.") " 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) { sealed class TransactionEncoderException(message: String, cause: Throwable? = null) : SdkException(message, cause) {
class FetchParamsException(message: String) : TransactionEncoderException("Failed to fetch params due to: $message") class FetchParamsException(message: String) : TransactionEncoderException("Failed to fetch params due to: $message")
object MissingParamsException : TransactionEncoderException( object MissingParamsException : TransactionEncoderException(

View File

@ -9,6 +9,14 @@ import java.math.RoundingMode
import java.text.NumberFormat import java.text.NumberFormat
import java.util.* 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 //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 // For now, just make these vars so at least they could be modified in one place
object Conversions { object Conversions {
@ -29,11 +37,15 @@ object Conversions {
/** /**
* Format a Zatoshi value into Zec with the given number of digits, represented as a string. * Format a Zatoshi value into ZEC with the given number of digits, represented as a string.
* Start with Zatoshi -> End with Zec. * 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. * @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( inline fun Long?.convertZatoshiToZecString(
maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits, 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. * Format a ZEC value into ZEC with the given number of digits, represented as a string.
* Start with ZeC -> End with Zec. * 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. * @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( inline fun Double?.toZecString(
maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits, 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. * Format a Zatoshi value into ZEC with the given number of decimal places, represented as a string.
* Start with ZeC -> End with Zec. * 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. * @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( inline fun BigDecimal?.toZecString(
maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits, 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. * @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( inline fun Double?.toUsdString(
maxDecimals: Int = USD_FORMATTER.maximumFractionDigits, 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. * Format a USD 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. * 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. * @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( inline fun BigDecimal?.toUsdString(
maxDecimals: Int = USD_FORMATTER.maximumFractionDigits, 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 * Create a number formatter for use with converting currency to strings. This probably isn't needed
* the other formatting functions leverage this, instead. Leverages the default rounding mode for zec found in * externally since the other formatting functions leverage this, instead. Leverages the default
* ZEC_FORMATTER. * 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 { inline fun currencyFormatter(maxDecimals: Int, minDecimals: Int): NumberFormat {
return NumberFormat.getInstance(Locale.getDefault()).apply { 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 * Convert a Zatoshi value into ZEC, right-padded to the given number of fraction digits,
* order to preserve rounding that minimizes cumulative error when applied repeatedly over a sequence of calculations. * represented as a BigDecimal in order to preserve rounding that minimizes cumulative error when
* Start with Zatoshi -> End with Zec. * 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 { 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 { inline fun BigDecimal?.convertZecToZatoshi(): Long {
if (this == null) return 0L 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() 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. * Format a Double ZEC value as a BigDecimal ZEC value, right-padded to the given number of fraction
* Start with Zec -> End with Zec. * 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 { 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. * 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 { inline fun Double?.convertZecToZatoshi(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): Long {
return this.toZec(decimals).convertZecToZatoshi() return this.toZec(decimals).convertZecToZatoshi()
} }
/** /**
* Format a BigDecimal Zec value as a BigDecimal Zec value, right-padded to the given number of fraction digits. * Format a BigDecimal ZEC value as a BigDecimal ZEC value, right-padded to the given number of
* Start with Zec -> End with Zec. * 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 { inline fun BigDecimal?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal {
return (this ?: BigDecimal.ZERO).setScale(decimals, ZEC_FORMATTER.roundingMode) 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 { 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 { inline fun BigDecimal?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits): BigDecimal {
return (this ?: BigDecimal.ZERO).setScale(decimals, USD_FORMATTER.roundingMode) 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. * 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 * @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 { inline fun BigDecimal?.convertZecToUsd(zecPrice: BigDecimal): BigDecimal {
if(this == null) return BigDecimal.ZERO 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) return this.multiply(zecPrice, MathContext.DECIMAL128)
} }
/** /**
* Convert this USD value to ZEC, using the given price per ZEC. * 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 { inline fun BigDecimal?.convertUsdToZec(zecPrice: BigDecimal): BigDecimal {
if(this == null) return BigDecimal.ZERO 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) 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 { inline fun BigDecimal.convertCurrency(zecPrice: BigDecimal, isUsd: Boolean): BigDecimal {
return if (isUsd) { 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. * 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? { inline fun String?.safelyConvertToBigDecimal(): BigDecimal? {
if (this.isNullOrEmpty()) return BigDecimal.ZERO 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') 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) { fun <T, S> Flow<T>.onFirstWith(scope: CoroutineScope, block: suspend (T) -> S) {
scope.launch { scope.launch {
onEach { 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) { suspend fun <T, S> Flow<T>.onFirst(block: suspend (T) -> S) {
onEach { onEach {
block(it) 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. * A tiny log.
*/ */
interface Twig { interface Twig {
/**
* Log the message. Simple.
*/
fun twig(logMessage: String = "") fun twig(logMessage: String = "")
/**
* Bundles twigs together
*/
operator fun plus(twig: Twig): Twig { operator fun plus(twig: Twig): Twig {
// if the other twig is a composite twig, let it handle the addition // 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)) 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. * A tiny log that does nothing. No one hears this twig fall in the woods.
*/ */
class SilentTwig : Twig { class SilentTwig : Twig {
/**
* Shh.
*/
override fun twig(logMessage: String) { override fun twig(logMessage: String) {
// shh // shh
} }
@ -89,6 +101,10 @@ open class TroubleshootingTwig(
val formatter: (String) -> String = spiffy(5), val formatter: (String) -> String = spiffy(5),
val printer: (String) -> Any = System.err::println val printer: (String) -> Any = System.err::println
) : Twig { ) : Twig {
/**
* Actually print and format the log message, unlike the SilentTwig, which does nothing.
*/
override fun twig(logMessage: String) { override fun twig(logMessage: String) {
printer(formatter(logMessage)) 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) { 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. 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) { 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 { internal fun dbExists(appContext: Context, dbFileName: String): Boolean {
return File(appContext.getDatabasePath(dbFileName).absolutePath).exists() 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. * Contract defining the exposed capabilities of the Rust backend.
* This is what welds the SDK to the Rust layer. * 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 { interface RustBackendWelding {

View File

@ -17,10 +17,10 @@ import java.util.concurrent.TimeUnit
/** /**
* Implementation of LightwalletService using gRPC for requests to lightwalletd. * Implementation of LightwalletService using gRPC for requests to lightwalletd.
* *
* @param channel the channel to use for communicating with the lightwalletd server. * @property 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 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. * 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. * created for streaming requests, it will use a deadline that is after the given duration from now.
*/ */
class LightWalletGrpcService private constructor( class LightWalletGrpcService private constructor(
@ -29,6 +29,16 @@ class LightWalletGrpcService private constructor(
private val streamingRequestTimeoutSec: Long = 90L private val streamingRequestTimeoutSec: Long = 90L
) : LightWalletService { ) : 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( constructor(
appContext: Context, appContext: Context,
host: String, host: String,
@ -38,12 +48,6 @@ class LightWalletGrpcService private constructor(
/* LightWalletService implementation */ /* 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> { override fun getBlockRange(heightRange: IntRange): List<CompactFormats.CompactBlock> {
channel.resetConnectBackoff() channel.resetConnectBackoff()
return channel.createStub(streamingRequestTimeoutSec).getBlockRange(heightRange.toBlockRange()).toList() return channel.createStub(streamingRequestTimeoutSec).getBlockRange(heightRange.toBlockRange()).toList()
@ -89,6 +93,11 @@ class LightWalletGrpcService private constructor(
} }
companion object { 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( fun createDefaultChannel(
appContext: Context, appContext: Context,
host: String, 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 * @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. * 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> 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.
*
* @return the latest block height known to the service.
*/ */
fun getLatestBlockHeight(): Int fun getLatestBlockHeight(): Int
/** /**
* Submit a raw transaction. * Submit a raw transaction.
*
* @return the response from the server.
*/ */
fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse fun submitTransaction(spendTransaction: ByteArray): Service.SendResponse

View File

@ -79,6 +79,9 @@ open class PagedTransactionRepository(
transactions.findMinedHeight(rawTransactionId) transactions.findMinedHeight(rawTransactionId)
} }
/**
* Close the underlying database.
*/
fun close() { fun close() {
derivedDataDb.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) { override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("PRAGMA foreign_keys = OFF;") database.execSQL("PRAGMA foreign_keys = OFF;")
database.execSQL( database.execSQL(

View File

@ -5,7 +5,10 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import cash.z.wallet.sdk.db.PendingTransactionDao import cash.z.wallet.sdk.db.PendingTransactionDao
import cash.z.wallet.sdk.db.PendingTransactionDb 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.ext.twig
import cash.z.wallet.sdk.service.LightWalletService import cash.z.wallet.sdk.service.LightWalletService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -13,13 +16,19 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.lang.IllegalStateException
import kotlin.math.max 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( class PersistentTransactionManager(
db: PendingTransactionDb, db: PendingTransactionDb,
private val encoder: TransactionEncoder, private val encoder: TransactionEncoder,
@ -52,10 +61,11 @@ class PersistentTransactionManager(
service service
) )
/**
* Initialize a [PendingTransaction] and then insert it in the database for monitoring and //
* follow-up. // OutboundTransactionManager implementation
*/ //
override suspend fun initSpend( override suspend fun initSpend(
zatoshiValue: Long, zatoshiValue: Long,
toAddress: String, toAddress: String,
@ -79,7 +89,8 @@ class PersistentTransactionManager(
twig("successfully created TX in DB") twig("successfully created TX in DB")
} }
} catch (t: Throwable) { } 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 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( override suspend fun encode(
spendingKey: String, spendingKey: String,
pendingTx: PendingTransaction pendingTx: PendingTransaction
): PendingTransaction = withContext(Dispatchers.IO) { ): PendingTransaction = withContext(Dispatchers.IO) {
twig("managing the creation of a transaction") 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 var tx = pendingTx as PendingTransactionEntity
try { try {
twig("beginning to encode transaction with : $encoder") 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}" val message = "failed to encode transaction due to : ${t.message} caused by: ${t.cause}"
twig(message) twig(message)
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 { } finally {
tx = tx.copy(encodeAttempts = max(1, tx.encodeAttempts + 1)) 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) { override suspend fun submit(pendingTx: PendingTransaction): PendingTransaction = withContext(Dispatchers.IO) {
// reload the tx to check for cancellation // 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 var tx = storedTx
try { try {
// do nothing when cancelled // do nothing when cancelled
if (!tx.isCancelled()) { 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 response = service.submitTransaction(tx.raw)
val error = response.errorCode < 0 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( tx = tx.copy(
errorMessage = if (error) response.errorMessage else null, errorMessage = if (error) response.errorMessage else null,
errorCode = response.errorCode, errorCode = response.errorCode,
@ -157,7 +162,11 @@ class PersistentTransactionManager(
val message = val message =
"Unknown error while submitting transaction: ${t.message} caused by: ${t.cause}" "Unknown error while submitting transaction: ${t.message} caused by: ${t.cause}"
twig(message) 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) safeUpdate(tx)
} }
@ -188,18 +197,33 @@ class PersistentTransactionManager(
override fun getAll() = _dao.getAll() 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 * 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. * happen within a try/catch block, surrounded by logging. So this helps with that.
*/ */
private suspend fun safeUpdate(tx: PendingTransactionEntity): PendingTransaction { private suspend fun safeUpdate(tx: PendingTransactionEntity): PendingTransaction {
return try { return try {
twig("updating tx into DB: $tx") twig("updating tx in DB: $tx")
pendingTransactionDao { update(tx) } pendingTransactionDao { update(tx) }
twig("successfully updated TX into DB") twig("successfully updated TX in DB")
tx tx
} catch (t: Throwable) { } 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 tx
} }
} }
@ -209,5 +233,12 @@ class PersistentTransactionManager(
_dao.block() _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 { 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( suspend fun createTransaction(
spendingKey: String, spendingKey: String,
@ -14,6 +24,23 @@ interface TransactionEncoder {
fromAccountIndex: Int = 0 fromAccountIndex: Int = 0
): EncodedTransaction ): 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 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 suspend fun isValidTransparentAddress(address: String): Boolean
} }

View File

@ -9,29 +9,110 @@ import kotlinx.coroutines.flow.Flow
* transactions through to completion. * transactions through to completion.
*/ */
interface OutboundTransactionManager { 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( suspend fun initSpend(
zatoshi: Long, zatoshi: Long,
toAddress: String, toAddress: String,
memo: String, memo: String,
fromAccountIndex: Int fromAccountIndex: Int
): PendingTransaction ): 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 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 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) 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> 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 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 suspend fun isValidTransparentAddress(address: String): Boolean
/** /**
* Attempt to cancel a transaction. * 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. * @return true when the transaction was able to be cancelled.
*/ */
suspend fun cancel(pendingTx: PendingTransaction): Boolean 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>> fun getAll(): Flow<List<PendingTransaction>>
} }
/**
* Interface for transaction errors.
*/
interface TransactionError { interface TransactionError {
/**
* The message associated with this error.
*/
val message: String 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. * Repository of wallet transactions, providing an agnostic interface to the underlying information.
*/ */
interface TransactionRepository { interface TransactionRepository {
/**
* The last height scanned by this repository.
*
* @return the last height scanned by this repository.
*/
fun lastScannedHeight(): Int 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 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? 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? suspend fun findMinedHeight(rawTransactionId: ByteArray): Int?
/** /**
@ -23,7 +52,10 @@ interface TransactionRepository {
// Transactions // Transactions
// //
/** A flow of all the inbound confirmed transactions */
val receivedTransactions: Flow<PagedList<ConfirmedTransaction>> val receivedTransactions: Flow<PagedList<ConfirmedTransaction>>
/** A flow of all the outbound confirmed transactions */
val sentTransactions: Flow<PagedList<ConfirmedTransaction>> val sentTransactions: Flow<PagedList<ConfirmedTransaction>>
/** A flow of all the inbound and outbound confirmed transactions */
val allTransactions: Flow<PagedList<ConfirmedTransaction>> 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 okio.Okio
import java.io.File 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( class WalletTransactionEncoder(
private val rustBackend: RustBackendWelding, private val rustBackend: RustBackendWelding,
private val repository: TransactionRepository private val repository: TransactionRepository
) : TransactionEncoder { ) : TransactionEncoder {
/** /**
* Creates a transaction, throwing an exception whenever things are missing. When the provided wallet implementation * Creates a transaction, throwing an exception whenever things are missing. When the provided
* doesn't throw an exception, we wrap the issue into a descriptive exception ourselves (rather than using * wallet implementation doesn't throw an exception, we wrap the issue into a descriptive
* double-bangs for things). * 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( override suspend fun createTransaction(
spendingKey: String, spendingKey: String,
@ -37,6 +54,10 @@ class WalletTransactionEncoder(
/** /**
* Utility function to help with validation. This is not called during [createTransaction] * 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. * 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) { override suspend fun isValidShieldedAddress(address: String): Boolean = withContext(IO) {
rustBackend.isValidShieldedAddr(address) rustBackend.isValidShieldedAddr(address)
@ -45,6 +66,10 @@ class WalletTransactionEncoder(
/** /**
* Utility function to help with validation. This is not called during [createTransaction] * 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. * 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) { override suspend fun isValidTransparentAddress(address: String): Boolean = withContext(IO) {
rustBackend.isValidTransparentAddr(address) rustBackend.isValidTransparentAddr(address)
@ -54,21 +79,23 @@ class WalletTransactionEncoder(
* Does the proofs and processing required to create a transaction to spend funds and inserts * 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. * the result in the database. On average, this call takes over 10 seconds.
* *
* @param value the zatoshi value to send * @param spendingKey the key associated with the notes that will be spent.
* @param toAddress the destination address * @param zatoshi the amount of zatoshi to send.
* @param memo the memo, which is not augmented in any way * @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 * @return the row id in the transactions table that contains the spend transaction or -1 if it
* or -1 if it failed * failed.
*/ */
private suspend fun createSpend( private suspend fun createSpend(
spendingKey: String, spendingKey: String,
value: Long, zatoshi: Long,
toAddress: String, toAddress: String,
memo: ByteArray? = byteArrayOf(), memo: ByteArray? = byteArrayOf(),
fromAccountIndex: Int = 0 fromAccountIndex: Int = 0
): Long = withContext(IO) { ): Long = withContext(IO) {
twigTask("creating transaction to spend $value zatoshi to" + twigTask("creating transaction to spend $zatoshi zatoshi to" +
" ${toAddress.masked()} with memo $memo") { " ${toAddress.masked()} with memo $memo") {
try { try {
ensureParams((rustBackend as RustBackend).pathParamsDir) ensureParams((rustBackend as RustBackend).pathParamsDir)
@ -77,7 +104,7 @@ class WalletTransactionEncoder(
fromAccountIndex, fromAccountIndex,
spendingKey, spendingKey,
toAddress, toAddress,
value, zatoshi,
memo memo
) )
} catch (t: Throwable) { } catch (t: Throwable) {
@ -166,6 +193,8 @@ class WalletTransactionEncoder(
/** /**
* Http client is only used for downloading sapling spend and output params data, which are * Http client is only used for downloading sapling spend and output params data, which are
* necessary for the wallet to scan blocks. * necessary for the wallet to scan blocks.
*
* @return an http client suitable for downloading params data.
*/ */
private fun createHttpClient(): OkHttpClient { private fun createHttpClient(): OkHttpClient {
//TODO: add logging and timeouts //TODO: add logging and timeouts