From a43070cd6aaef7439e1a6bc36f14b80a6db356b0 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Thu, 27 Feb 2020 12:28:10 -0500 Subject: [PATCH] Documented everything public-facing. This should bring documentation coverage to 100% --- .../java/cash/z/wallet/sdk/Initializer.kt | 49 ++- .../java/cash/z/wallet/sdk/Synchronizer.kt | 4 +- .../cash/z/wallet/sdk/db/CompactBlockDb.kt | 9 + .../cash/z/wallet/sdk/db/DerivedDataDb.kt | 29 +- .../z/wallet/sdk/db/PendingTransactionDb.kt | 9 + .../cash/z/wallet/sdk/exception/Exceptions.kt | 29 +- .../z/wallet/sdk/ext/CurrencyFormatter.kt | 213 ++++++++++--- src/main/java/cash/z/wallet/sdk/ext/Flow.kt | 7 + .../java/cash/z/wallet/sdk/ext/SharedPrefs.kt | 39 --- src/main/java/cash/z/wallet/sdk/ext/Twig.kt | 16 + .../cash/z/wallet/sdk/ext/WalletService.kt | 14 + .../z/wallet/sdk/jni/RustBackendWelding.kt | 2 + .../sdk/service/LightWalletGrpcService.kt | 27 +- .../wallet/sdk/service/LightWalletService.kt | 7 + .../transaction/PagedTransactionRepository.kt | 5 +- .../PersistentTransactionManager.kt | 85 +++-- .../PersistentTransactionSender.kt | 295 ------------------ .../sdk/transaction/TransactionEncoder.kt | 29 +- .../sdk/transaction/TransactionManager.kt | 81 +++++ .../sdk/transaction/TransactionRepository.kt | 32 ++ .../sdk/transaction/TransactionSender.kt | 23 -- .../transaction/WalletTransactionEncoder.kt | 51 ++- 22 files changed, 592 insertions(+), 463 deletions(-) delete mode 100644 src/main/java/cash/z/wallet/sdk/ext/SharedPrefs.kt delete mode 100644 src/main/java/cash/z/wallet/sdk/transaction/PersistentTransactionSender.kt delete mode 100644 src/main/java/cash/z/wallet/sdk/transaction/TransactionSender.kt diff --git a/src/main/java/cash/z/wallet/sdk/Initializer.kt b/src/main/java/cash/z/wallet/sdk/Initializer.kt index 1e077051..f7230291 100644 --- a/src/main/java/cash/z/wallet/sdk/Initializer.kt +++ b/src/main/java/cash/z/wallet/sdk/Initializer.kt @@ -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 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") + } + } } } } diff --git a/src/main/java/cash/z/wallet/sdk/Synchronizer.kt b/src/main/java/cash/z/wallet/sdk/Synchronizer.kt index dc3c8511..8910f8ca 100644 --- a/src/main/java/cash/z/wallet/sdk/Synchronizer.kt +++ b/src/main/java/cash/z/wallet/sdk/Synchronizer.kt @@ -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 diff --git a/src/main/java/cash/z/wallet/sdk/db/CompactBlockDb.kt b/src/main/java/cash/z/wallet/sdk/db/CompactBlockDb.kt index 78bf2a5c..bd4d2a1b 100644 --- a/src/main/java/cash/z/wallet/sdk/db/CompactBlockDb.kt +++ b/src/main/java/cash/z/wallet/sdk/db/CompactBlockDb.kt @@ -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) diff --git a/src/main/java/cash/z/wallet/sdk/db/DerivedDataDb.kt b/src/main/java/cash/z/wallet/sdk/db/DerivedDataDb.kt index d7ba8282..bd7909ad 100644 --- a/src/main/java/cash/z/wallet/sdk/db/DerivedDataDb.kt +++ b/src/main/java/cash/z/wallet/sdk/db/DerivedDataDb.kt @@ -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 + /** + * Query all transactions, joining outbound and inbound transactions into the same table. + */ @Query(""" SELECT transactions.id_tx AS id, transactions.block AS minedHeight, diff --git a/src/main/java/cash/z/wallet/sdk/db/PendingTransactionDb.kt b/src/main/java/cash/z/wallet/sdk/db/PendingTransactionDb.kt index fcc540d5..cedc5589 100644 --- a/src/main/java/cash/z/wallet/sdk/db/PendingTransactionDb.kt +++ b/src/main/java/cash/z/wallet/sdk/db/PendingTransactionDb.kt @@ -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) diff --git a/src/main/java/cash/z/wallet/sdk/exception/Exceptions.kt b/src/main/java/cash/z/wallet/sdk/exception/Exceptions.kt index d0fe5dba..b385d9f6 100644 --- a/src/main/java/cash/z/wallet/sdk/exception/Exceptions.kt +++ b/src/main/java/cash/z/wallet/sdk/exception/Exceptions.kt @@ -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( diff --git a/src/main/java/cash/z/wallet/sdk/ext/CurrencyFormatter.kt b/src/main/java/cash/z/wallet/sdk/ext/CurrencyFormatter.kt index 7b7fe319..9abf2fc5 100644 --- a/src/main/java/cash/z/wallet/sdk/ext/CurrencyFormatter.kt +++ b/src/main/java/cash/z/wallet/sdk/ext/CurrencyFormatter.kt @@ -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') \ No newline at end of file diff --git a/src/main/java/cash/z/wallet/sdk/ext/Flow.kt b/src/main/java/cash/z/wallet/sdk/ext/Flow.kt index dc75e7c9..8ece2e3e 100644 --- a/src/main/java/cash/z/wallet/sdk/ext/Flow.kt +++ b/src/main/java/cash/z/wallet/sdk/ext/Flow.kt @@ -38,6 +38,10 @@ fun Flow.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 Flow.onFirstWith(scope: CoroutineScope, block: suspend (T) -> S) { scope.launch { onEach { @@ -46,6 +50,9 @@ fun Flow.onFirstWith(scope: CoroutineScope, block: suspend (T) -> S) { } } +/** + * Utility for performing the given action on the first emission of a flow. + */ suspend fun Flow.onFirst(block: suspend (T) -> S) { onEach { block(it) diff --git a/src/main/java/cash/z/wallet/sdk/ext/SharedPrefs.kt b/src/main/java/cash/z/wallet/sdk/ext/SharedPrefs.kt deleted file mode 100644 index 5b2e349f..00000000 --- a/src/main/java/cash/z/wallet/sdk/ext/SharedPrefs.kt +++ /dev/null @@ -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 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") - } -} \ No newline at end of file diff --git a/src/main/java/cash/z/wallet/sdk/ext/Twig.kt b/src/main/java/cash/z/wallet/sdk/ext/Twig.kt index c4034e86..b7a94a85 100644 --- a/src/main/java/cash/z/wallet/sdk/ext/Twig.kt +++ b/src/main/java/cash/z/wallet/sdk/ext/Twig.kt @@ -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 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)) } diff --git a/src/main/java/cash/z/wallet/sdk/ext/WalletService.kt b/src/main/java/cash/z/wallet/sdk/ext/WalletService.kt index 81c1af3f..439098be 100644 --- a/src/main/java/cash/z/wallet/sdk/ext/WalletService.kt +++ b/src/main/java/cash/z/wallet/sdk/ext/WalletService.kt @@ -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() } \ No newline at end of file diff --git a/src/main/java/cash/z/wallet/sdk/jni/RustBackendWelding.kt b/src/main/java/cash/z/wallet/sdk/jni/RustBackendWelding.kt index 488016e4..9e74f770 100644 --- a/src/main/java/cash/z/wallet/sdk/jni/RustBackendWelding.kt +++ b/src/main/java/cash/z/wallet/sdk/jni/RustBackendWelding.kt @@ -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 { diff --git a/src/main/java/cash/z/wallet/sdk/service/LightWalletGrpcService.kt b/src/main/java/cash/z/wallet/sdk/service/LightWalletGrpcService.kt index 6ec3a887..ac2d8999 100644 --- a/src/main/java/cash/z/wallet/sdk/service/LightWalletGrpcService.kt +++ b/src/main/java/cash/z/wallet/sdk/service/LightWalletGrpcService.kt @@ -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 { 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, diff --git a/src/main/java/cash/z/wallet/sdk/service/LightWalletService.kt b/src/main/java/cash/z/wallet/sdk/service/LightWalletService.kt index eb07f0bf..cd7d61ad 100644 --- a/src/main/java/cash/z/wallet/sdk/service/LightWalletService.kt +++ b/src/main/java/cash/z/wallet/sdk/service/LightWalletService.kt @@ -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 /** * 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 diff --git a/src/main/java/cash/z/wallet/sdk/transaction/PagedTransactionRepository.kt b/src/main/java/cash/z/wallet/sdk/transaction/PagedTransactionRepository.kt index 977e7c7f..65f5beb5 100644 --- a/src/main/java/cash/z/wallet/sdk/transaction/PagedTransactionRepository.kt +++ b/src/main/java/cash/z/wallet/sdk/transaction/PagedTransactionRepository.kt @@ -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( diff --git a/src/main/java/cash/z/wallet/sdk/transaction/PersistentTransactionManager.kt b/src/main/java/cash/z/wallet/sdk/transaction/PersistentTransactionManager.kt index b0169698..d5ca0d45 100644 --- a/src/main/java/cash/z/wallet/sdk/transaction/PersistentTransactionManager.kt +++ b/src/main/java/cash/z/wallet/sdk/transaction/PersistentTransactionManager.kt @@ -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 + } } diff --git a/src/main/java/cash/z/wallet/sdk/transaction/PersistentTransactionSender.kt b/src/main/java/cash/z/wallet/sdk/transaction/PersistentTransactionSender.kt deleted file mode 100644 index eec6df5c..00000000 --- a/src/main/java/cash/z/wallet/sdk/transaction/PersistentTransactionSender.kt +++ /dev/null @@ -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 -// private var monitoringJob: Job? = null -// private val initialMonitorDelay = 45_000L -// private var listenerChannel: SendChannel>? = null -// override var onSubmissionError: ((Throwable) -> Boolean)? = null -// private var updateResult: CompletableDeferred? = 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 { -// 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>) { -// 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? = null -// -// private suspend fun notifyIfChanged(currentSentTxs: List) = 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?, -// currentSents: List -// ): 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 = 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() -//} \ No newline at end of file diff --git a/src/main/java/cash/z/wallet/sdk/transaction/TransactionEncoder.kt b/src/main/java/cash/z/wallet/sdk/transaction/TransactionEncoder.kt index 362b9295..693944aa 100644 --- a/src/main/java/cash/z/wallet/sdk/transaction/TransactionEncoder.kt +++ b/src/main/java/cash/z/wallet/sdk/transaction/TransactionEncoder.kt @@ -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 } diff --git a/src/main/java/cash/z/wallet/sdk/transaction/TransactionManager.kt b/src/main/java/cash/z/wallet/sdk/transaction/TransactionManager.kt index 4a8c184f..c33a4327 100644 --- a/src/main/java/cash/z/wallet/sdk/transaction/TransactionManager.kt +++ b/src/main/java/cash/z/wallet/sdk/transaction/TransactionManager.kt @@ -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 + /** + * 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> } +/** + * Interface for transaction errors. + */ interface TransactionError { + /** + * The message associated with this error. + */ val message: String } \ No newline at end of file diff --git a/src/main/java/cash/z/wallet/sdk/transaction/TransactionRepository.kt b/src/main/java/cash/z/wallet/sdk/transaction/TransactionRepository.kt index e64ecc58..25115652 100644 --- a/src/main/java/cash/z/wallet/sdk/transaction/TransactionRepository.kt +++ b/src/main/java/cash/z/wallet/sdk/transaction/TransactionRepository.kt @@ -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> + /** A flow of all the outbound confirmed transactions */ val sentTransactions: Flow> + /** A flow of all the inbound and outbound confirmed transactions */ val allTransactions: Flow> } \ No newline at end of file diff --git a/src/main/java/cash/z/wallet/sdk/transaction/TransactionSender.kt b/src/main/java/cash/z/wallet/sdk/transaction/TransactionSender.kt deleted file mode 100644 index b2de99b4..00000000 --- a/src/main/java/cash/z/wallet/sdk/transaction/TransactionSender.kt +++ /dev/null @@ -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>) - /** 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 \ No newline at end of file diff --git a/src/main/java/cash/z/wallet/sdk/transaction/WalletTransactionEncoder.kt b/src/main/java/cash/z/wallet/sdk/transaction/WalletTransactionEncoder.kt index 4e61b2c1..09caff35 100644 --- a/src/main/java/cash/z/wallet/sdk/transaction/WalletTransactionEncoder.kt +++ b/src/main/java/cash/z/wallet/sdk/transaction/WalletTransactionEncoder.kt @@ -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